1use 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#[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
84pub 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
107pub 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(®istration.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
136pub 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
167pub 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(®istration.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
210pub 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
220pub 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}