1use std::{
7 collections::{BTreeMap, BTreeSet},
8 error::Error,
9 fmt, fs,
10 path::{Path, PathBuf},
11};
12
13mod changes;
14pub(crate) mod feature_flags;
15mod git;
16mod grep;
17mod identity;
18mod ids;
19mod languages;
20mod parser;
21mod pipeline;
22mod scope;
23mod snapshot;
24pub(crate) mod source_roots;
25
26#[cfg(test)]
27mod tests;
28
29#[cfg(test)]
30#[path = "source_declaration_tests.rs"]
31mod source_declaration_tests;
32
33use crate::domain::{
34 CodeFileFingerprint, CodeIndexMode, CodeIndexSnapshot, CodePathTombstone,
35 CodeRepositoryRegistration, CodeRepositorySelector, RepositoryCodeRange,
36};
37
38use changes::{GitChange, diff_changes, tracked_paths, worktree_changed_paths};
39use git::{
40 git_bytes, git_object_exists, git_optional, resolve_git_root, resolve_ref, resolve_tree,
41};
42pub(crate) use grep::{
43 SOURCE_GREP_CANDIDATE_FILE_LIMIT, SourceGrepKind, SourceGrepMatch, SourceGrepOutcome,
44 SourceGrepRequest, source_grep_matches,
45};
46use ids::{stable_content_hash, stable_hash64, stable_id};
47use parser::parse_indexed_file;
48pub use pipeline::{CodeIndexPlan, prepare_full_index_plan};
49use scope::{
50 load_ignore_rules, load_ignore_rules_from_commit, path_is_selected_with_rules,
51 path_scope_overlaps, selection_exclusion_reason,
52};
53pub use scope::{partition_changed_paths_for_selector, preview_repository_scope};
54use snapshot::SnapshotBuild;
55
56#[cfg(test)]
57use identity::resolve_reference_targets;
58
59#[cfg(test)]
60use languages::language_id;
61
62#[cfg(test)]
63use scope::{path_is_selected, path_scope_allows};
64
65#[derive(Debug)]
67pub enum CodeIndexError {
68 Io(std::io::Error),
69 Git { args: Vec<String>, message: String },
70 TreeSitter(String),
71 InvalidInput(String),
72}
73
74impl fmt::Display for CodeIndexError {
75 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76 match self {
77 Self::Io(error) => write!(formatter, "code index I/O failed: {error}"),
78 Self::Git { args, message } => {
79 write!(formatter, "git command failed ({args:?}): {message}")
80 }
81 Self::TreeSitter(message) => write!(formatter, "tree-sitter parse failed: {message}"),
82 Self::InvalidInput(message) => write!(formatter, "invalid code index input: {message}"),
83 }
84 }
85}
86
87impl Error for CodeIndexError {}
88
89impl From<std::io::Error> for CodeIndexError {
90 fn from(error: std::io::Error) -> Self {
91 Self::Io(error)
92 }
93}
94
95pub fn register_repository(
97 path: impl AsRef<Path>,
98 alias: impl Into<String>,
99 path_filters: Vec<String>,
100 language_filters: Vec<String>,
101) -> Result<CodeRepositoryRegistration, CodeIndexError> {
102 let root = resolve_git_root(path.as_ref())?;
103 let root_identity = root.display().to_string();
104 let origin = git_optional(&root, ["config", "--get", "remote.origin.url"])?
105 .unwrap_or_else(|| root_identity.clone());
106 let repository_id = stable_id("repo", [origin.as_str(), root_identity.as_str()]);
107
108 CodeRepositoryRegistration::new(
109 repository_id,
110 alias,
111 root_identity,
112 path_filters,
113 language_filters,
114 )
115 .map_err(|error| CodeIndexError::InvalidInput(error.to_string()))
116}
117
118pub fn build_index_snapshot(
120 registration: &CodeRepositoryRegistration,
121 selector: &CodeRepositorySelector,
122 mode: CodeIndexMode,
123 previous_hashes: Vec<CodeFileFingerprint>,
124) -> Result<CodeIndexSnapshot, CodeIndexError> {
125 let root = PathBuf::from(®istration.root_path);
126 let previous_hashes = previous_hashes
127 .into_iter()
128 .map(|fingerprint| (fingerprint.path, fingerprint.blob_hash))
129 .collect::<BTreeMap<_, _>>();
130
131 match mode {
132 CodeIndexMode::Full => build_full_snapshot(registration, selector, &root),
133 CodeIndexMode::Incremental { base_ref, head_ref } => build_incremental_snapshot(
134 registration,
135 selector,
136 &root,
137 &base_ref,
138 &head_ref,
139 &previous_hashes,
140 ),
141 CodeIndexMode::WorktreeOverlay => {
142 build_worktree_overlay_snapshot(registration, selector, &root, &previous_hashes)
143 }
144 }
145}
146
147pub fn changed_paths_for_diff(
149 root_path: impl AsRef<Path>,
150 base_ref: &str,
151 head_ref: &str,
152) -> Result<Vec<String>, CodeIndexError> {
153 let changes = diff_changes(root_path.as_ref(), base_ref, head_ref)?;
154
155 Ok(impact_paths_from_changes(changes))
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub(crate) struct SourceDeclarationMatch {
161 pub(crate) path: String,
162 pub(crate) excerpt: String,
163 pub(crate) byte_range: RepositoryCodeRange,
164 pub(crate) line_range: RepositoryCodeRange,
165}
166
167const MAX_SOURCE_DECLARATION_FILES: usize = 8;
168const MAX_SOURCE_DECLARATION_BYTES: usize = 512 * 1024;
169
170pub(crate) fn source_declarations_for_identity(
172 registration: &CodeRepositoryRegistration,
173 commit: &str,
174 paths: Vec<String>,
175 identity: &str,
176) -> Result<Vec<SourceDeclarationMatch>, CodeIndexError> {
177 git::validate_git_ref_arg("commit", commit)?;
178 if !simple_source_identifier(identity) {
179 return Ok(Vec::new());
180 }
181
182 let root = PathBuf::from(®istration.root_path);
183 let mut seen = BTreeSet::new();
184 let mut files_considered = 0usize;
185 let mut matches = Vec::new();
186 for path in paths {
187 if files_considered >= MAX_SOURCE_DECLARATION_FILES {
188 break;
189 }
190 if !safe_git_blob_path(&path) || !seen.insert(path.clone()) {
191 continue;
192 }
193 files_considered += 1;
194 let object = format!("{commit}:{path}");
195 let Ok(bytes) = git::git_bytes(&root, ["show", &object]) else {
196 continue;
197 };
198 if bytes.len() > MAX_SOURCE_DECLARATION_BYTES {
199 continue;
200 }
201 let Ok(content) = std::str::from_utf8(&bytes) else {
202 continue;
203 };
204 if let Some(declaration) = first_source_declaration_match(&path, content, identity)? {
205 matches.push(declaration);
206 }
207 }
208
209 Ok(matches)
210}
211
212fn first_source_declaration_match(
213 path: &str,
214 content: &str,
215 identity: &str,
216) -> Result<Option<SourceDeclarationMatch>, CodeIndexError> {
217 let mut byte_start = 0usize;
218 for (line_index, line) in content.split_inclusive('\n').enumerate() {
219 let line_without_newline = line.trim_end_matches(['\r', '\n']);
220 let byte_end = byte_start + line_without_newline.len();
221 if source_line_defines_identity(line_without_newline.trim(), identity) {
222 let line_number = line_index + 1;
223 return Ok(Some(SourceDeclarationMatch {
224 path: path.to_owned(),
225 excerpt: line_without_newline.trim().to_owned(),
226 byte_range: RepositoryCodeRange::new("byte_range", byte_start, byte_end)
227 .map_err(|error| CodeIndexError::InvalidInput(error.to_string()))?,
228 line_range: RepositoryCodeRange::new("line_range", line_number, line_number)
229 .map_err(|error| CodeIndexError::InvalidInput(error.to_string()))?,
230 }));
231 }
232 byte_start += line.len();
233 }
234
235 Ok(None)
236}
237
238pub(crate) fn source_line_defines_identity(line: &str, identity: &str) -> bool {
239 if line.is_empty() || !line_contains_identifier(line, identity) {
240 return false;
241 }
242 if line.starts_with("typedef ") || line.contains(" typedef ") {
243 return true;
244 }
245 if line.starts_with("#define ") {
246 return line
247 .strip_prefix("#define ")
248 .is_some_and(|suffix| line_starts_with_identifier(suffix, identity));
249 }
250 if line
251 .strip_prefix("using ")
252 .or_else(|| line.strip_prefix("typealias "))
253 .is_some_and(|suffix| line_starts_with_identifier(suffix, identity))
254 {
255 return true;
256 }
257 if ["struct ", "class ", "enum ", "union ", "interface "]
258 .into_iter()
259 .filter_map(|prefix| line.strip_prefix(prefix))
260 .any(|suffix| line_starts_with_identifier(suffix, identity))
261 {
262 return true;
263 }
264
265 line.contains('(') && line_looks_like_function_definition(line, identity)
266}
267
268fn line_looks_like_function_definition(line: &str, identity: &str) -> bool {
269 line.match_indices(identity).any(|(identity_start, _)| {
270 if !identifier_match_has_boundaries(line, identity, identity_start) {
271 return false;
272 }
273 let prefix = line[..identity_start].trim_start();
274 let suffix = line[identity_start + identity.len()..].trim_start();
275 if !suffix.starts_with('(') || prefix.contains('=') {
276 return false;
277 }
278 if prefix.chars().next_back().is_some_and(|character| {
279 matches!(character, '(' | '.' | '>') || (character == ':' && !prefix.ends_with("::"))
280 }) {
281 return false;
282 }
283 !matches!(
284 prefix.split_whitespace().next(),
285 Some("if" | "for" | "while" | "switch" | "return")
286 )
287 })
288}
289
290fn line_starts_with_identifier(line: &str, identifier: &str) -> bool {
291 let trimmed = line.trim_start();
292 trimmed.starts_with(identifier)
293 && trimmed
294 .get(identifier.len()..)
295 .is_some_and(|suffix| suffix.chars().next().is_none_or(|c| !is_identifier_char(c)))
296}
297
298fn line_contains_identifier(line: &str, identifier: &str) -> bool {
299 line.match_indices(identifier)
300 .any(|(start, _)| identifier_match_has_boundaries(line, identifier, start))
301}
302
303fn identifier_match_has_boundaries(line: &str, identifier: &str, start: usize) -> bool {
304 let end = start + identifier.len();
305 line.get(..start).is_some_and(|prefix| {
306 prefix
307 .chars()
308 .next_back()
309 .is_none_or(|c| !is_identifier_char(c))
310 }) && line
311 .get(end..)
312 .is_some_and(|suffix| suffix.chars().next().is_none_or(|c| !is_identifier_char(c)))
313}
314
315pub(crate) fn simple_source_identifier(value: &str) -> bool {
316 !value.is_empty() && value.chars().all(is_identifier_char)
317}
318
319fn is_identifier_char(character: char) -> bool {
320 character.is_ascii_alphanumeric() || character == '_'
321}
322
323fn safe_git_blob_path(path: &str) -> bool {
324 !path.is_empty()
325 && !path.starts_with('/')
326 && !path.contains('\\')
327 && !path.contains('\0')
328 && !path.contains('\n')
329 && !path.contains('\r')
330 && path.split('/').all(|part| !part.is_empty() && part != "..")
331}
332
333fn impact_paths_from_changes(changes: Vec<GitChange>) -> Vec<String> {
334 let mut paths = Vec::new();
335 for change in changes {
336 match change {
337 GitChange::AddedOrModified { path }
338 | GitChange::Deleted { path }
339 | GitChange::TypeChanged { path } => paths.push(path),
340 GitChange::Renamed { old_path, new_path } => {
341 paths.push(old_path);
342 paths.push(new_path);
343 }
344 GitChange::Copied { new_path, .. } => paths.push(new_path),
345 }
346 }
347 paths.sort();
348 paths.dedup();
349
350 paths
351}
352
353pub fn deleted_symbol_names_for_diff(
355 registration: &CodeRepositoryRegistration,
356 selector: &CodeRepositorySelector,
357 base_ref: &str,
358 head_ref: &str,
359) -> Result<Vec<String>, CodeIndexError> {
360 let root = PathBuf::from(®istration.root_path);
361 let base_commit = resolve_ref(&root, base_ref)?;
362 let head_commit = resolve_ref(&root, head_ref)?;
363 let changes = diff_changes(&root, base_ref, head_ref)?;
364 let ignore_rules = load_ignore_rules_from_commit(&root, &head_commit)?;
365 let mut names = Vec::new();
366
367 for change in changes {
368 let deleted_path = match change {
369 GitChange::Deleted { path } | GitChange::Renamed { old_path: path, .. } => path,
370 GitChange::AddedOrModified { .. }
371 | GitChange::Copied { .. }
372 | GitChange::TypeChanged { .. } => continue,
373 };
374 if !path_is_selected_with_rules(&deleted_path, registration, selector, &ignore_rules) {
375 continue;
376 }
377 let bytes = git_bytes(&root, ["show", &format!("{base_commit}:{deleted_path}")])?;
378 let mut build = SnapshotBuild::new_with_selector(
379 registration,
380 selector,
381 base_commit.clone(),
382 "deleted-symbol-seed".to_owned(),
383 true,
384 1,
385 0,
386 );
387 parse_indexed_file(&mut build, &deleted_path, &bytes)?;
388 names.extend(build.symbols.into_iter().map(|symbol| symbol.name));
389 }
390 names.sort();
391 names.dedup();
392
393 Ok(names)
394}
395
396pub fn resolve_repository_ref(
398 root_path: impl AsRef<Path>,
399 ref_selector: &str,
400) -> Result<String, CodeIndexError> {
401 let root = resolve_git_root(root_path.as_ref())?;
402
403 resolve_ref(&root, ref_selector)
404}
405
406pub fn resolve_repository_snapshot(
408 root_path: impl AsRef<Path>,
409 ref_selector: &str,
410) -> Result<(String, String), CodeIndexError> {
411 let root = resolve_git_root(root_path.as_ref())?;
412 let commit = resolve_ref(&root, ref_selector)?;
413 let tree_hash = resolve_tree(&root, &commit)?;
414
415 Ok((commit, tree_hash))
416}
417
418fn build_full_snapshot(
419 registration: &CodeRepositoryRegistration,
420 selector: &CodeRepositorySelector,
421 root: &Path,
422) -> Result<CodeIndexSnapshot, CodeIndexError> {
423 let commit = resolve_ref(root, &selector.ref_selector)?;
424 let tree_hash = resolve_tree(root, &commit)?;
425 let ignore_rules = load_ignore_rules_from_commit(root, &commit)?;
426 let paths = tracked_paths(root, &commit)?
427 .into_iter()
428 .filter(|path| {
429 selection_exclusion_reason(path, registration, selector, &ignore_rules).is_none()
430 })
431 .collect::<Vec<_>>();
432 let mut build = SnapshotBuild::new_with_selector(
433 registration,
434 selector,
435 commit,
436 tree_hash,
437 true,
438 paths.len(),
439 0,
440 );
441
442 for path in paths {
443 let bytes = git_bytes(root, ["show", &format!("{}:{path}", build.commit)])?;
444 parse_indexed_file(&mut build, &path, &bytes)?;
445 }
446
447 Ok(build.finish())
448}
449
450fn build_incremental_snapshot(
451 registration: &CodeRepositoryRegistration,
452 selector: &CodeRepositorySelector,
453 root: &Path,
454 base_ref: &str,
455 head_ref: &str,
456 previous_hashes: &BTreeMap<String, String>,
457) -> Result<CodeIndexSnapshot, CodeIndexError> {
458 let base_commit = resolve_ref(root, base_ref)?;
459 let commit = resolve_ref(root, head_ref)?;
460 let tree_hash = resolve_tree(root, &commit)?;
461 let changes = diff_changes(root, base_ref, head_ref)?;
462 let base_ignore_rules = load_ignore_rules_from_commit(root, &base_commit)?;
463 let ignore_rules = load_ignore_rules_from_commit(root, &commit)?;
464 let mut build = SnapshotBuild::new_with_selector(
465 registration,
466 selector,
467 commit,
468 tree_hash,
469 false,
470 changes.len(),
471 0,
472 );
473 build.base_resolved_commit_sha = Some(base_commit.clone());
474
475 for change in changes {
476 match change {
477 GitChange::Deleted { path } => {
478 if path_is_selected_with_rules(&path, registration, selector, &base_ignore_rules) {
479 build.deleted_paths.push(path);
480 }
481 }
482 GitChange::Renamed { old_path, new_path } => {
483 if path_is_selected_with_rules(
484 &old_path,
485 registration,
486 selector,
487 &base_ignore_rules,
488 ) {
489 build.deleted_paths.push(old_path.clone());
490 build.tombstones.push(CodePathTombstone {
491 repository_id: registration.repository_id.clone(),
492 source_scope: build.source_scope.clone(),
493 old_path,
494 new_path: Some(new_path.clone()),
495 base_ref: base_ref.to_owned(),
496 head_ref: head_ref.to_owned(),
497 });
498 }
499 parse_changed_path(
500 &mut build,
501 registration,
502 selector,
503 root,
504 &new_path,
505 previous_hashes,
506 &ignore_rules,
507 )?;
508 }
509 GitChange::Copied { old_path, new_path } => {
510 if path_is_selected_with_rules(&new_path, registration, selector, &ignore_rules) {
511 build.tombstones.push(CodePathTombstone {
512 repository_id: registration.repository_id.clone(),
513 source_scope: build.source_scope.clone(),
514 old_path,
515 new_path: Some(new_path.clone()),
516 base_ref: base_ref.to_owned(),
517 head_ref: head_ref.to_owned(),
518 });
519 }
520 parse_changed_path(
521 &mut build,
522 registration,
523 selector,
524 root,
525 &new_path,
526 previous_hashes,
527 &ignore_rules,
528 )?;
529 }
530 GitChange::AddedOrModified { path } | GitChange::TypeChanged { path } => {
531 parse_changed_path(
532 &mut build,
533 registration,
534 selector,
535 root,
536 &path,
537 previous_hashes,
538 &ignore_rules,
539 )?;
540 }
541 }
542 }
543
544 Ok(build.finish())
545}
546
547fn build_worktree_overlay_snapshot(
548 registration: &CodeRepositoryRegistration,
549 selector: &CodeRepositorySelector,
550 root: &Path,
551 previous_hashes: &BTreeMap<String, String>,
552) -> Result<CodeIndexSnapshot, CodeIndexError> {
553 let commit = resolve_ref(root, &selector.ref_selector)?;
554 let head_commit = resolve_ref(root, "HEAD")?;
555 if commit != head_commit {
556 return Err(CodeIndexError::InvalidInput(format!(
557 "worktree overlay ref '{}' resolves to {}, but checked-out HEAD is {}",
558 selector.ref_selector, commit, head_commit
559 )));
560 }
561 let status = git_bytes(
562 root,
563 ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
564 )?;
565 let changes = worktree_changed_paths(&status);
566 if changes.is_empty() {
567 return build_full_snapshot(registration, selector, root);
568 }
569 let mut overlay_hash_input = Vec::new();
570 let mut deleted_paths = Vec::new();
571 let mut files_to_parse = Vec::new();
572 let mut skipped_unchanged_count = 0;
573 let ignore_rules = load_ignore_rules(root)?;
574
575 for change in &changes {
576 if let Some(deleted_path) = &change.deleted_source {
577 if path_is_selected_with_rules(deleted_path, registration, selector, &ignore_rules) {
578 overlay_hash_input.extend_from_slice(b"D\0");
579 overlay_hash_input.extend_from_slice(deleted_path.as_bytes());
580 overlay_hash_input.push(0);
581 deleted_paths.push(deleted_path.clone());
582 }
583 }
584 let path = &change.path;
585 if !path_scope_overlaps(path, registration, selector) {
586 continue;
587 }
588 let full_path = root.join(path);
589 let metadata = match fs::symlink_metadata(&full_path) {
590 Ok(metadata) => metadata,
591 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
592 if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
593 overlay_hash_input.extend_from_slice(b"D\0");
594 overlay_hash_input.extend_from_slice(path.as_bytes());
595 overlay_hash_input.push(0);
596 deleted_paths.push(path.clone());
597 }
598 continue;
599 }
600 Err(error) => return Err(error.into()),
601 };
602 let file_type = metadata.file_type();
603 if file_type.is_symlink() {
604 if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
605 record_worktree_status_marker(path, &mut overlay_hash_input);
606 }
607 continue;
608 }
609 if file_type.is_dir() {
610 if !change.is_untracked() || !worktree_directory_is_expandable(root, path)? {
611 if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
612 record_worktree_status_marker(path, &mut overlay_hash_input);
613 }
614 continue;
615 }
616 for nested_path in worktree_directory_files(root, path)? {
617 if path_is_selected_with_rules(&nested_path, registration, selector, &ignore_rules)
618 {
619 record_worktree_file(
620 root,
621 &nested_path,
622 previous_hashes,
623 &mut overlay_hash_input,
624 &mut files_to_parse,
625 &mut skipped_unchanged_count,
626 )?;
627 }
628 }
629 continue;
630 }
631 if !file_type.is_file() {
632 if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
633 record_worktree_status_marker(path, &mut overlay_hash_input);
634 }
635 continue;
636 }
637 if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
638 record_worktree_file(
639 root,
640 path,
641 previous_hashes,
642 &mut overlay_hash_input,
643 &mut files_to_parse,
644 &mut skipped_unchanged_count,
645 )?;
646 }
647 }
648 if overlay_hash_input.is_empty() {
649 return build_full_snapshot(registration, selector, root);
650 }
651
652 let overlay_hash = format!("{:016x}", stable_hash64(&overlay_hash_input));
653 let tree_hash = format!("worktree:{overlay_hash}");
654 let overlay_commit = format!("worktree:{commit}:{overlay_hash}");
655 let mut build = SnapshotBuild::new_with_selector(
656 registration,
657 selector,
658 overlay_commit,
659 tree_hash,
660 false,
661 changes.len(),
662 skipped_unchanged_count,
663 );
664 build.base_resolved_commit_sha = Some(commit);
665 build.deleted_paths = deleted_paths;
666
667 for (path, bytes) in files_to_parse {
668 parse_indexed_file(&mut build, &path, &bytes)?;
669 }
670
671 Ok(build.finish())
672}
673
674fn record_worktree_status_marker(path: &str, overlay_hash_input: &mut Vec<u8>) {
675 overlay_hash_input.extend_from_slice(b"S\0");
676 overlay_hash_input.extend_from_slice(path.as_bytes());
677 overlay_hash_input.push(0);
678}
679
680fn record_worktree_file(
681 root: &Path,
682 path: &str,
683 previous_hashes: &BTreeMap<String, String>,
684 overlay_hash_input: &mut Vec<u8>,
685 files_to_parse: &mut Vec<(String, Vec<u8>)>,
686 skipped_unchanged_count: &mut usize,
687) -> Result<(), CodeIndexError> {
688 let bytes = fs::read(root.join(path))?;
689 let blob_hash = stable_content_hash(&bytes);
690 overlay_hash_input.extend_from_slice(b"F\0");
691 overlay_hash_input.extend_from_slice(path.as_bytes());
692 overlay_hash_input.push(0);
693 overlay_hash_input.extend_from_slice(blob_hash.as_bytes());
694 overlay_hash_input.push(0);
695 if previous_hashes.get(path) == Some(&blob_hash) {
696 *skipped_unchanged_count += 1;
697 return Ok(());
698 }
699 files_to_parse.push((path.to_owned(), bytes));
700
701 Ok(())
702}
703
704fn worktree_directory_files(
705 root: &Path,
706 relative_dir: &str,
707) -> Result<Vec<String>, CodeIndexError> {
708 if !worktree_directory_is_expandable(root, relative_dir)? {
709 return Ok(Vec::new());
710 }
711 let mut files = Vec::new();
712 collect_worktree_directory_files(root, Path::new(relative_dir), &mut files)?;
713 files.sort();
714
715 Ok(files)
716}
717
718fn worktree_directory_is_expandable(
719 root: &Path,
720 relative_dir: &str,
721) -> Result<bool, CodeIndexError> {
722 let full_path = root.join(relative_dir);
723 let metadata = fs::symlink_metadata(&full_path)?;
724 if !metadata.file_type().is_dir() {
725 return Ok(false);
726 }
727
728 Ok(!contains_git_metadata(root, Path::new(relative_dir))?)
729}
730
731fn contains_git_metadata(root: &Path, relative: &Path) -> Result<bool, CodeIndexError> {
732 match fs::symlink_metadata(root.join(relative).join(".git")) {
733 Ok(_) => Ok(true),
734 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
735 Err(error) => Err(error.into()),
736 }
737}
738
739fn collect_worktree_directory_files(
740 root: &Path,
741 relative: &Path,
742 files: &mut Vec<String>,
743) -> Result<(), CodeIndexError> {
744 for entry in fs::read_dir(root.join(relative))? {
745 let entry = entry?;
746 let path = relative.join(entry.file_name());
747 let file_type = entry.file_type()?;
748 if file_type.is_dir() {
749 if entry.file_name() == ".git" || contains_git_metadata(root, &path)? {
750 continue;
751 }
752 collect_worktree_directory_files(root, &path, files)?;
753 } else if file_type.is_file() {
754 files.push(path.to_string_lossy().replace('\\', "/"));
755 }
756 }
757
758 Ok(())
759}
760
761fn parse_changed_path(
762 build: &mut SnapshotBuild,
763 registration: &CodeRepositoryRegistration,
764 selector: &CodeRepositorySelector,
765 root: &Path,
766 path: &str,
767 previous_hashes: &BTreeMap<String, String>,
768 ignore_rules: &[scope::IgnoreRule],
769) -> Result<(), CodeIndexError> {
770 if !path_is_selected_with_rules(path, registration, selector, ignore_rules) {
771 return Ok(());
772 }
773 let object = format!("{}:{path}", build.commit);
774 let bytes = git_bytes(root, ["show", &object])?;
775 let blob_hash = stable_content_hash(&bytes);
776 if previous_hashes.get(path) == Some(&blob_hash) {
777 build.skipped_unchanged_count += 1;
778 return Ok(());
779 }
780
781 parse_indexed_file(build, path, &bytes)
782}