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;
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/// Blocking code index failure.
85#[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
114/// Validates a Git worktree and creates a stable repository registration.
115pub 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
164/// Builds a code index snapshot from a clean Git commit or incremental diff.
165pub 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(&registration.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
193/// Returns changed paths for impact analysis without mutating the code index.
194pub 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/// Exact source declaration recovered from an indexed Git snapshot.
205#[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
216/// Reads a bounded set of indexed Git blobs and returns exact declaration lines.
217pub(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(&registration.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
399/// Extracts symbol names removed by a diff so impact can include deleted APIs.
400pub 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(&registration.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
449/// Resolves a repository ref selector to the exact commit used by storage.
450pub 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
459/// Resolves a repository ref selector to the exact commit and tree hash.
460pub 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(&registration.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(&registration.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}