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