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