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