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