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,
8    error::Error,
9    fmt, fs,
10    path::{Path, PathBuf},
11};
12
13mod changes;
14mod git;
15mod identity;
16mod ids;
17mod languages;
18mod parser;
19mod pipeline;
20mod scope;
21mod snapshot;
22
23#[cfg(test)]
24mod tests;
25
26use crate::domain::{
27    CodeFileFingerprint, CodeIndexMode, CodeIndexSnapshot, CodePathTombstone,
28    CodeRepositoryRegistration, CodeRepositorySelector,
29};
30
31use changes::{GitChange, diff_changes, tracked_paths, worktree_changed_paths};
32use git::{
33    git_bytes, git_object_exists, git_optional, resolve_git_root, resolve_ref, resolve_tree,
34};
35use ids::{stable_content_hash, stable_hash64, stable_id};
36use parser::parse_indexed_file;
37pub use pipeline::{CodeIndexPlan, prepare_full_index_plan};
38use scope::{
39    load_ignore_rules, load_ignore_rules_from_commit, path_is_selected_with_rules,
40    path_scope_overlaps, selection_exclusion_reason,
41};
42pub use scope::{partition_changed_paths_for_selector, preview_repository_scope};
43use snapshot::SnapshotBuild;
44
45#[cfg(test)]
46use identity::resolve_reference_targets;
47
48#[cfg(test)]
49use languages::language_id;
50
51#[cfg(test)]
52use scope::{path_is_selected, path_scope_allows};
53
54/// Blocking code index failure.
55#[derive(Debug)]
56pub enum CodeIndexError {
57    Io(std::io::Error),
58    Git { args: Vec<String>, message: String },
59    TreeSitter(String),
60    InvalidInput(String),
61}
62
63impl fmt::Display for CodeIndexError {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Io(error) => write!(formatter, "code index I/O failed: {error}"),
67            Self::Git { args, message } => {
68                write!(formatter, "git command failed ({args:?}): {message}")
69            }
70            Self::TreeSitter(message) => write!(formatter, "tree-sitter parse failed: {message}"),
71            Self::InvalidInput(message) => write!(formatter, "invalid code index input: {message}"),
72        }
73    }
74}
75
76impl Error for CodeIndexError {}
77
78impl From<std::io::Error> for CodeIndexError {
79    fn from(error: std::io::Error) -> Self {
80        Self::Io(error)
81    }
82}
83
84/// Validates a Git worktree and creates a stable repository registration.
85pub fn register_repository(
86    path: impl AsRef<Path>,
87    alias: impl Into<String>,
88    path_filters: Vec<String>,
89    language_filters: Vec<String>,
90) -> Result<CodeRepositoryRegistration, CodeIndexError> {
91    let root = resolve_git_root(path.as_ref())?;
92    let root_identity = root.display().to_string();
93    let origin = git_optional(&root, ["config", "--get", "remote.origin.url"])?
94        .unwrap_or_else(|| root_identity.clone());
95    let repository_id = stable_id("repo", [origin.as_str(), root_identity.as_str()]);
96
97    CodeRepositoryRegistration::new(
98        repository_id,
99        alias,
100        root_identity,
101        path_filters,
102        language_filters,
103    )
104    .map_err(|error| CodeIndexError::InvalidInput(error.to_string()))
105}
106
107/// Builds a code index snapshot from a clean Git commit or incremental diff.
108pub fn build_index_snapshot(
109    registration: &CodeRepositoryRegistration,
110    selector: &CodeRepositorySelector,
111    mode: CodeIndexMode,
112    previous_hashes: Vec<CodeFileFingerprint>,
113) -> Result<CodeIndexSnapshot, CodeIndexError> {
114    let root = PathBuf::from(&registration.root_path);
115    let previous_hashes = previous_hashes
116        .into_iter()
117        .map(|fingerprint| (fingerprint.path, fingerprint.blob_hash))
118        .collect::<BTreeMap<_, _>>();
119
120    match mode {
121        CodeIndexMode::Full => build_full_snapshot(registration, selector, &root),
122        CodeIndexMode::Incremental { base_ref, head_ref } => build_incremental_snapshot(
123            registration,
124            selector,
125            &root,
126            &base_ref,
127            &head_ref,
128            &previous_hashes,
129        ),
130        CodeIndexMode::WorktreeOverlay => {
131            build_worktree_overlay_snapshot(registration, selector, &root, &previous_hashes)
132        }
133    }
134}
135
136/// Returns changed paths for impact analysis without mutating the code index.
137pub fn changed_paths_for_diff(
138    root_path: impl AsRef<Path>,
139    base_ref: &str,
140    head_ref: &str,
141) -> Result<Vec<String>, CodeIndexError> {
142    let changes = diff_changes(root_path.as_ref(), base_ref, head_ref)?;
143
144    Ok(impact_paths_from_changes(changes))
145}
146
147fn impact_paths_from_changes(changes: Vec<GitChange>) -> Vec<String> {
148    let mut paths = Vec::new();
149    for change in changes {
150        match change {
151            GitChange::AddedOrModified { path }
152            | GitChange::Deleted { path }
153            | GitChange::TypeChanged { path } => paths.push(path),
154            GitChange::Renamed { old_path, new_path } => {
155                paths.push(old_path);
156                paths.push(new_path);
157            }
158            GitChange::Copied { new_path, .. } => paths.push(new_path),
159        }
160    }
161    paths.sort();
162    paths.dedup();
163
164    paths
165}
166
167/// Extracts symbol names removed by a diff so impact can include deleted APIs.
168pub fn deleted_symbol_names_for_diff(
169    registration: &CodeRepositoryRegistration,
170    selector: &CodeRepositorySelector,
171    base_ref: &str,
172    head_ref: &str,
173) -> Result<Vec<String>, CodeIndexError> {
174    let root = PathBuf::from(&registration.root_path);
175    let base_commit = resolve_ref(&root, base_ref)?;
176    let head_commit = resolve_ref(&root, head_ref)?;
177    let changes = diff_changes(&root, base_ref, head_ref)?;
178    let ignore_rules = load_ignore_rules_from_commit(&root, &head_commit)?;
179    let mut names = Vec::new();
180
181    for change in changes {
182        let deleted_path = match change {
183            GitChange::Deleted { path } | GitChange::Renamed { old_path: path, .. } => path,
184            GitChange::AddedOrModified { .. }
185            | GitChange::Copied { .. }
186            | GitChange::TypeChanged { .. } => continue,
187        };
188        if !path_is_selected_with_rules(&deleted_path, registration, selector, &ignore_rules) {
189            continue;
190        }
191        let bytes = git_bytes(&root, ["show", &format!("{base_commit}:{deleted_path}")])?;
192        let mut build = SnapshotBuild::new_with_selector(
193            registration,
194            selector,
195            base_commit.clone(),
196            "deleted-symbol-seed".to_owned(),
197            true,
198            1,
199            0,
200        );
201        parse_indexed_file(&mut build, &deleted_path, &bytes)?;
202        names.extend(build.symbols.into_iter().map(|symbol| symbol.name));
203    }
204    names.sort();
205    names.dedup();
206
207    Ok(names)
208}
209
210/// Resolves a repository ref selector to the exact commit used by storage.
211pub fn resolve_repository_ref(
212    root_path: impl AsRef<Path>,
213    ref_selector: &str,
214) -> Result<String, CodeIndexError> {
215    let root = resolve_git_root(root_path.as_ref())?;
216
217    resolve_ref(&root, ref_selector)
218}
219
220/// Resolves a repository ref selector to the exact commit and tree hash.
221pub fn resolve_repository_snapshot(
222    root_path: impl AsRef<Path>,
223    ref_selector: &str,
224) -> Result<(String, String), CodeIndexError> {
225    let root = resolve_git_root(root_path.as_ref())?;
226    let commit = resolve_ref(&root, ref_selector)?;
227    let tree_hash = resolve_tree(&root, &commit)?;
228
229    Ok((commit, tree_hash))
230}
231
232fn build_full_snapshot(
233    registration: &CodeRepositoryRegistration,
234    selector: &CodeRepositorySelector,
235    root: &Path,
236) -> Result<CodeIndexSnapshot, CodeIndexError> {
237    let commit = resolve_ref(root, &selector.ref_selector)?;
238    let tree_hash = resolve_tree(root, &commit)?;
239    let ignore_rules = load_ignore_rules_from_commit(root, &commit)?;
240    let paths = tracked_paths(root, &commit)?
241        .into_iter()
242        .filter(|path| {
243            selection_exclusion_reason(path, registration, selector, &ignore_rules).is_none()
244        })
245        .collect::<Vec<_>>();
246    let mut build = SnapshotBuild::new_with_selector(
247        registration,
248        selector,
249        commit,
250        tree_hash,
251        true,
252        paths.len(),
253        0,
254    );
255
256    for path in paths {
257        let bytes = git_bytes(root, ["show", &format!("{}:{path}", build.commit)])?;
258        parse_indexed_file(&mut build, &path, &bytes)?;
259    }
260
261    Ok(build.finish())
262}
263
264fn build_incremental_snapshot(
265    registration: &CodeRepositoryRegistration,
266    selector: &CodeRepositorySelector,
267    root: &Path,
268    base_ref: &str,
269    head_ref: &str,
270    previous_hashes: &BTreeMap<String, String>,
271) -> Result<CodeIndexSnapshot, CodeIndexError> {
272    let base_commit = resolve_ref(root, base_ref)?;
273    let commit = resolve_ref(root, head_ref)?;
274    let tree_hash = resolve_tree(root, &commit)?;
275    let changes = diff_changes(root, base_ref, head_ref)?;
276    let base_ignore_rules = load_ignore_rules_from_commit(root, &base_commit)?;
277    let ignore_rules = load_ignore_rules_from_commit(root, &commit)?;
278    let mut build = SnapshotBuild::new_with_selector(
279        registration,
280        selector,
281        commit,
282        tree_hash,
283        false,
284        changes.len(),
285        0,
286    );
287    build.base_resolved_commit_sha = Some(base_commit.clone());
288
289    for change in changes {
290        match change {
291            GitChange::Deleted { path } => {
292                if path_is_selected_with_rules(&path, registration, selector, &base_ignore_rules) {
293                    build.deleted_paths.push(path);
294                }
295            }
296            GitChange::Renamed { old_path, new_path } => {
297                if path_is_selected_with_rules(
298                    &old_path,
299                    registration,
300                    selector,
301                    &base_ignore_rules,
302                ) {
303                    build.deleted_paths.push(old_path.clone());
304                    build.tombstones.push(CodePathTombstone {
305                        repository_id: registration.repository_id.clone(),
306                        source_scope: build.source_scope.clone(),
307                        old_path,
308                        new_path: Some(new_path.clone()),
309                        base_ref: base_ref.to_owned(),
310                        head_ref: head_ref.to_owned(),
311                    });
312                }
313                parse_changed_path(
314                    &mut build,
315                    registration,
316                    selector,
317                    root,
318                    &new_path,
319                    previous_hashes,
320                    &ignore_rules,
321                )?;
322            }
323            GitChange::Copied { old_path, new_path } => {
324                if path_is_selected_with_rules(&new_path, registration, selector, &ignore_rules) {
325                    build.tombstones.push(CodePathTombstone {
326                        repository_id: registration.repository_id.clone(),
327                        source_scope: build.source_scope.clone(),
328                        old_path,
329                        new_path: Some(new_path.clone()),
330                        base_ref: base_ref.to_owned(),
331                        head_ref: head_ref.to_owned(),
332                    });
333                }
334                parse_changed_path(
335                    &mut build,
336                    registration,
337                    selector,
338                    root,
339                    &new_path,
340                    previous_hashes,
341                    &ignore_rules,
342                )?;
343            }
344            GitChange::AddedOrModified { path } | GitChange::TypeChanged { path } => {
345                parse_changed_path(
346                    &mut build,
347                    registration,
348                    selector,
349                    root,
350                    &path,
351                    previous_hashes,
352                    &ignore_rules,
353                )?;
354            }
355        }
356    }
357
358    Ok(build.finish())
359}
360
361fn build_worktree_overlay_snapshot(
362    registration: &CodeRepositoryRegistration,
363    selector: &CodeRepositorySelector,
364    root: &Path,
365    previous_hashes: &BTreeMap<String, String>,
366) -> Result<CodeIndexSnapshot, CodeIndexError> {
367    let commit = resolve_ref(root, &selector.ref_selector)?;
368    let head_commit = resolve_ref(root, "HEAD")?;
369    if commit != head_commit {
370        return Err(CodeIndexError::InvalidInput(format!(
371            "worktree overlay ref '{}' resolves to {}, but checked-out HEAD is {}",
372            selector.ref_selector, commit, head_commit
373        )));
374    }
375    let status = git_bytes(
376        root,
377        ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
378    )?;
379    let changes = worktree_changed_paths(&status);
380    if changes.is_empty() {
381        return build_full_snapshot(registration, selector, root);
382    }
383    let mut overlay_hash_input = Vec::new();
384    let mut deleted_paths = Vec::new();
385    let mut files_to_parse = Vec::new();
386    let mut skipped_unchanged_count = 0;
387    let ignore_rules = load_ignore_rules(root)?;
388
389    for change in &changes {
390        if let Some(deleted_path) = &change.deleted_source {
391            if path_is_selected_with_rules(deleted_path, registration, selector, &ignore_rules) {
392                overlay_hash_input.extend_from_slice(b"D\0");
393                overlay_hash_input.extend_from_slice(deleted_path.as_bytes());
394                overlay_hash_input.push(0);
395                deleted_paths.push(deleted_path.clone());
396            }
397        }
398        let path = &change.path;
399        if !path_scope_overlaps(path, registration, selector) {
400            continue;
401        }
402        let full_path = root.join(path);
403        let metadata = match fs::symlink_metadata(&full_path) {
404            Ok(metadata) => metadata,
405            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
406                if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
407                    overlay_hash_input.extend_from_slice(b"D\0");
408                    overlay_hash_input.extend_from_slice(path.as_bytes());
409                    overlay_hash_input.push(0);
410                    deleted_paths.push(path.clone());
411                }
412                continue;
413            }
414            Err(error) => return Err(error.into()),
415        };
416        let file_type = metadata.file_type();
417        if file_type.is_symlink() {
418            if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
419                record_worktree_status_marker(path, &mut overlay_hash_input);
420            }
421            continue;
422        }
423        if file_type.is_dir() {
424            if !change.is_untracked() || !worktree_directory_is_expandable(root, path)? {
425                if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
426                    record_worktree_status_marker(path, &mut overlay_hash_input);
427                }
428                continue;
429            }
430            for nested_path in worktree_directory_files(root, path)? {
431                if path_is_selected_with_rules(&nested_path, registration, selector, &ignore_rules)
432                {
433                    record_worktree_file(
434                        root,
435                        &nested_path,
436                        previous_hashes,
437                        &mut overlay_hash_input,
438                        &mut files_to_parse,
439                        &mut skipped_unchanged_count,
440                    )?;
441                }
442            }
443            continue;
444        }
445        if !file_type.is_file() {
446            if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
447                record_worktree_status_marker(path, &mut overlay_hash_input);
448            }
449            continue;
450        }
451        if path_is_selected_with_rules(path, registration, selector, &ignore_rules) {
452            record_worktree_file(
453                root,
454                path,
455                previous_hashes,
456                &mut overlay_hash_input,
457                &mut files_to_parse,
458                &mut skipped_unchanged_count,
459            )?;
460        }
461    }
462    if overlay_hash_input.is_empty() {
463        return build_full_snapshot(registration, selector, root);
464    }
465
466    let overlay_hash = format!("{:016x}", stable_hash64(&overlay_hash_input));
467    let tree_hash = format!("worktree:{overlay_hash}");
468    let overlay_commit = format!("worktree:{commit}:{overlay_hash}");
469    let mut build = SnapshotBuild::new_with_selector(
470        registration,
471        selector,
472        overlay_commit,
473        tree_hash,
474        false,
475        changes.len(),
476        skipped_unchanged_count,
477    );
478    build.base_resolved_commit_sha = Some(commit);
479    build.deleted_paths = deleted_paths;
480
481    for (path, bytes) in files_to_parse {
482        parse_indexed_file(&mut build, &path, &bytes)?;
483    }
484
485    Ok(build.finish())
486}
487
488fn record_worktree_status_marker(path: &str, overlay_hash_input: &mut Vec<u8>) {
489    overlay_hash_input.extend_from_slice(b"S\0");
490    overlay_hash_input.extend_from_slice(path.as_bytes());
491    overlay_hash_input.push(0);
492}
493
494fn record_worktree_file(
495    root: &Path,
496    path: &str,
497    previous_hashes: &BTreeMap<String, String>,
498    overlay_hash_input: &mut Vec<u8>,
499    files_to_parse: &mut Vec<(String, Vec<u8>)>,
500    skipped_unchanged_count: &mut usize,
501) -> Result<(), CodeIndexError> {
502    let bytes = fs::read(root.join(path))?;
503    let blob_hash = stable_content_hash(&bytes);
504    overlay_hash_input.extend_from_slice(b"F\0");
505    overlay_hash_input.extend_from_slice(path.as_bytes());
506    overlay_hash_input.push(0);
507    overlay_hash_input.extend_from_slice(blob_hash.as_bytes());
508    overlay_hash_input.push(0);
509    if previous_hashes.get(path) == Some(&blob_hash) {
510        *skipped_unchanged_count += 1;
511        return Ok(());
512    }
513    files_to_parse.push((path.to_owned(), bytes));
514
515    Ok(())
516}
517
518fn worktree_directory_files(
519    root: &Path,
520    relative_dir: &str,
521) -> Result<Vec<String>, CodeIndexError> {
522    if !worktree_directory_is_expandable(root, relative_dir)? {
523        return Ok(Vec::new());
524    }
525    let mut files = Vec::new();
526    collect_worktree_directory_files(root, Path::new(relative_dir), &mut files)?;
527    files.sort();
528
529    Ok(files)
530}
531
532fn worktree_directory_is_expandable(
533    root: &Path,
534    relative_dir: &str,
535) -> Result<bool, CodeIndexError> {
536    let full_path = root.join(relative_dir);
537    let metadata = fs::symlink_metadata(&full_path)?;
538    if !metadata.file_type().is_dir() {
539        return Ok(false);
540    }
541
542    Ok(!contains_git_metadata(root, Path::new(relative_dir))?)
543}
544
545fn contains_git_metadata(root: &Path, relative: &Path) -> Result<bool, CodeIndexError> {
546    match fs::symlink_metadata(root.join(relative).join(".git")) {
547        Ok(_) => Ok(true),
548        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
549        Err(error) => Err(error.into()),
550    }
551}
552
553fn collect_worktree_directory_files(
554    root: &Path,
555    relative: &Path,
556    files: &mut Vec<String>,
557) -> Result<(), CodeIndexError> {
558    for entry in fs::read_dir(root.join(relative))? {
559        let entry = entry?;
560        let path = relative.join(entry.file_name());
561        let file_type = entry.file_type()?;
562        if file_type.is_dir() {
563            if entry.file_name() == ".git" || contains_git_metadata(root, &path)? {
564                continue;
565            }
566            collect_worktree_directory_files(root, &path, files)?;
567        } else if file_type.is_file() {
568            files.push(path.to_string_lossy().replace('\\', "/"));
569        }
570    }
571
572    Ok(())
573}
574
575fn parse_changed_path(
576    build: &mut SnapshotBuild,
577    registration: &CodeRepositoryRegistration,
578    selector: &CodeRepositorySelector,
579    root: &Path,
580    path: &str,
581    previous_hashes: &BTreeMap<String, String>,
582    ignore_rules: &[scope::IgnoreRule],
583) -> Result<(), CodeIndexError> {
584    if !path_is_selected_with_rules(path, registration, selector, ignore_rules) {
585        return Ok(());
586    }
587    let object = format!("{}:{path}", build.commit);
588    let bytes = git_bytes(root, ["show", &object])?;
589    let blob_hash = stable_content_hash(&bytes);
590    if previous_hashes.get(path) == Some(&blob_hash) {
591        build.skipped_unchanged_count += 1;
592        return Ok(());
593    }
594
595    parse_indexed_file(build, path, &bytes)
596}