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