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