Skip to main content

relay_knowledge/code/
mod.rs

1//! Git snapshot and tree-sitter code index construction.
2//!
3//! This module owns blocking Git, filesystem, and parser work. Application
4//! methods run these workflows behind explicit blocking-worker boundaries.
5
6use 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/// Blocking code index failure.
84#[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
113/// Validates a Git worktree and creates a stable repository registration.
114pub 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
163/// Builds a code index snapshot from a clean Git commit or incremental diff.
164pub 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(&registration.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
192/// Returns changed paths for impact analysis without mutating the code index.
193pub 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/// Exact source declaration recovered from an indexed Git snapshot.
204#[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
215/// Reads a bounded set of indexed Git blobs and returns exact declaration lines.
216pub(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(&registration.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
398/// Extracts symbol names removed by a diff so impact can include deleted APIs.
399pub 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(&registration.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
448/// Resolves a repository ref selector to the exact commit used by storage.
449pub 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
458/// Resolves a repository ref selector to the exact commit and tree hash.
459pub 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(&registration.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(&registration.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}