1use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::sync::atomic::{AtomicUsize, Ordering};
17use std::time::{Duration, Instant};
18
19use crate::entity::{EntityKey, Kind};
20use crate::git;
21
22#[derive(Debug, Clone)]
26pub struct SetSpec {
27 pub name: String,
28 pub roots: Vec<PathBuf>,
29 pub include: Vec<String>,
30 pub exclude: Vec<String>,
31}
32
33#[derive(Debug, Clone)]
35pub struct Discovery {
36 pub entities: Vec<EntityKey>,
41 pub directories_visited: usize,
44 pub abandoned: bool,
47}
48
49pub(crate) const ABANDON_AFTER: Duration = Duration::from_secs(30);
55
56pub fn discover(spec: &SetSpec) -> Discovery {
62 walk(spec, ABANDON_AFTER, None)
63}
64
65pub(crate) fn discover_watched_with_deadline(
79 spec: &SetSpec,
80 progress: Arc<AtomicUsize>,
81 abandon_after: Duration,
82) -> Discovery {
83 walk(spec, abandon_after, Some(&progress))
84}
85
86pub fn count(spec: &SetSpec) -> usize {
91 discover(spec).entities.len()
92}
93
94#[derive(Debug, Clone)]
99pub(crate) struct DiscoveredEntity {
100 pub key: EntityKey,
101 pub kind: Kind,
102 pub common_dir: Arc<Path>,
103 pub repo: Option<Arc<gix::ThreadSafeRepository>>,
109 pub display_name_override: Option<Arc<str>>,
116}
117
118pub(crate) fn resolve(
131 spec: &SetSpec,
132 boundaries: &[EntityKey],
133) -> (Vec<DiscoveredEntity>, Vec<(EntityKey, String)>) {
134 resolve_with_cache(spec, boundaries, &HashMap::new())
135}
136
137pub(crate) fn resolve_with_cache(
145 spec: &SetSpec,
146 boundaries: &[EntityKey],
147 cache: &HashMap<EntityKey, Arc<gix::ThreadSafeRepository>>,
148) -> (Vec<DiscoveredEntity>, Vec<(EntityKey, String)>) {
149 let globs = Globs::compile(&spec.include, &spec.exclude);
150 let mut entities = Vec::with_capacity(boundaries.len());
151 let mut failures = Vec::new();
152
153 for key in boundaries {
154 let path = key.path();
155 let resolved = if let Some(cached) = cache.get(key) {
161 Ok((
162 git::resolve_from_open(cached.to_thread_local()),
163 Some(Arc::clone(cached)),
164 ))
165 } else {
166 git::resolve_boundary(path).map(|resolved| (resolved, None))
167 };
168 let (kind, common_dir, submodules, repo) = match resolved {
169 Ok((resolved, cached_repo)) => {
170 let git::Resolved {
171 kind,
172 common_dir,
173 submodules,
174 repo,
175 } = resolved;
176 let repo = cached_repo.or_else(|| Some(Arc::new(repo)));
177 (kind, common_dir, submodules, repo)
178 }
179 Err(_) => (
184 Kind::Repo,
185 Arc::from(path.join(".git")),
186 Ok(Vec::new()),
187 None,
188 ),
189 };
190
191 entities.push(DiscoveredEntity {
192 key: key.clone(),
193 kind,
194 common_dir: Arc::clone(&common_dir),
195 repo,
196 display_name_override: None,
197 });
198
199 match submodules {
200 Ok(submodules) => {
201 for submodule in submodules {
202 let submodule_path = path.join(&submodule.relative_path);
203 let submodule_path =
204 fs::canonicalize(&submodule_path).unwrap_or(submodule_path);
205 if !globs.admits(&submodule_path) {
206 continue;
207 }
208 let submodule_common_dir =
213 common_dir.join("modules").join(submodule.name.as_ref());
214 entities.push(DiscoveredEntity {
215 key: EntityKey::new(Arc::from(submodule_path.as_path())),
216 kind: Kind::Submodule,
217 common_dir: Arc::from(submodule_common_dir),
218 repo: None,
219 display_name_override: Some(Arc::from(
220 submodule.relative_path.to_string_lossy().as_ref(),
221 )),
222 });
223 }
224 }
225 Err(error) => failures.push((key.clone(), error.to_string())),
226 }
227 }
228
229 (entities, failures)
230}
231
232fn is_boundary(dir: &Path) -> bool {
234 dir.join(".git").exists()
235}
236
237struct Globs {
240 include: Vec<globset::GlobMatcher>,
241 exclude: Vec<globset::GlobMatcher>,
242}
243
244impl Globs {
245 fn compile(include: &[String], exclude: &[String]) -> Self {
246 let compile_all = |patterns: &[String]| {
247 patterns
248 .iter()
249 .filter_map(|pattern| globset::Glob::new(pattern).ok())
250 .map(|glob| glob.compile_matcher())
251 .collect()
252 };
253 Globs {
254 include: compile_all(include),
255 exclude: compile_all(exclude),
256 }
257 }
258
259 fn admits(&self, path: &Path) -> bool {
263 let included = self.include.is_empty() || self.include.iter().any(|m| m.is_match(path));
264 let excluded = self.exclude.iter().any(|m| m.is_match(path));
265 included && !excluded
266 }
267}
268
269fn walk(spec: &SetSpec, abandon_after: Duration, progress: Option<&AtomicUsize>) -> Discovery {
270 let globs = Globs::compile(&spec.include, &spec.exclude);
271 let mut entities = Vec::new();
272 let mut discovered_by_walk: HashSet<PathBuf> = HashSet::new();
278 let mut symlinks: Vec<PathBuf> = Vec::new();
283 let mut directories_visited = 0usize;
284 let started = Instant::now();
285 let mut abandoned = false;
286
287 'roots: for root in &spec.roots {
288 let mut stack = vec![root.clone()];
289 while let Some(dir) = stack.pop() {
290 directories_visited += 1;
291 if let Some(progress) = progress {
292 progress.store(directories_visited, Ordering::Relaxed);
293 }
294 if started.elapsed() >= abandon_after {
295 abandoned = true;
296 break 'roots;
297 }
298
299 if is_boundary(&dir) {
300 let canonical = fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
304 discovered_by_walk.insert(canonical.clone());
305 if globs.admits(&canonical) {
306 entities.push(EntityKey::new(Arc::from(canonical.as_path())));
307 }
308 continue;
309 }
310
311 let Ok(read_dir) = fs::read_dir(&dir) else {
312 continue;
313 };
314 for entry in read_dir.flatten() {
315 let entry_path = entry.path();
316 let Ok(file_type) = entry.file_type() else {
317 continue;
318 };
319
320 if file_type.is_symlink() {
321 symlinks.push(entry_path);
322 continue;
323 }
324
325 if file_type.is_dir() {
326 stack.push(entry_path);
327 }
328 }
329 }
330 }
331
332 for link in &symlinks {
333 resolve_symlink(link, &globs, &mut discovered_by_walk, &mut entities);
334 }
335
336 Discovery {
337 entities,
338 directories_visited,
339 abandoned,
340 }
341}
342
343fn resolve_symlink(
349 link: &Path,
350 globs: &Globs,
351 discovered_by_walk: &mut HashSet<PathBuf>,
352 entities: &mut Vec<EntityKey>,
353) {
354 let Ok(metadata) = fs::metadata(link) else {
355 return;
356 };
357 if !metadata.is_dir() {
358 return;
359 }
360 let Ok(target) = fs::canonicalize(link) else {
361 return;
362 };
363 if !is_boundary(&target) {
364 return;
367 }
368 if discovered_by_walk.contains(&target) {
369 return;
372 }
373 discovered_by_walk.insert(target.clone());
374 if globs.admits(&target) {
375 entities.push(EntityKey::new(Arc::from(target.as_path())));
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::os::unix::fs::symlink;
383
384 fn init_repo(path: &Path) {
385 fs::create_dir_all(path).expect("create repo dir");
386 gix::init(path).expect("init repo");
387 }
388
389 fn spec(roots: Vec<PathBuf>) -> SetSpec {
390 SetSpec {
391 name: "test".to_string(),
392 roots,
393 include: Vec::new(),
394 exclude: Vec::new(),
395 }
396 }
397
398 fn root_of(dir: &tempfile::TempDir) -> PathBuf {
402 dir.path().canonicalize().expect("canonicalize temp dir")
403 }
404
405 fn paths(discovery: &Discovery) -> Vec<PathBuf> {
406 let mut paths: Vec<PathBuf> = discovery
407 .entities
408 .iter()
409 .map(|key| key.path().to_path_buf())
410 .collect();
411 paths.sort();
412 paths
413 }
414
415 #[test]
416 fn a_lone_repo_at_the_root_is_discovered() {
417 let dir = tempfile::tempdir().expect("temp dir");
418 let root_dir = root_of(&dir);
419 let repo = root_dir.join("repo");
420 init_repo(&repo);
421
422 let discovery = discover(&spec(vec![root_dir.clone()]));
423
424 assert_eq!(paths(&discovery), vec![repo]);
425 assert!(!discovery.abandoned);
426 }
427
428 #[test]
434 fn count_matches_a_set_against_real_disposable_repositories_with_commits() {
435 let dir = tempfile::tempdir().expect("temp dir");
436 let root_dir = root_of(&dir);
437 let repo_a = root_dir.join("repo-a");
438 let repo_b = root_dir.join("repo-b");
439 init_repo(&repo_a);
440 init_repo(&repo_b);
441 crate::test_support::git(&repo_a, &["commit", "--allow-empty", "-m", "first"]);
442 crate::test_support::git(&repo_b, &["commit", "--allow-empty", "-m", "first"]);
443 fs::create_dir_all(root_dir.join("not-a-repo")).expect("create plain dir");
444
445 assert_eq!(count(&spec(vec![root_dir])), 2);
446 }
447
448 #[test]
455 fn the_walk_never_descends_past_a_repo_boundary() {
456 let dir = tempfile::tempdir().expect("temp dir");
457 let root_dir = root_of(&dir);
458 let outer = root_dir.join("outer");
459 init_repo(&outer);
460
461 let nested = outer.join("vendor").join("nested-repo");
463 init_repo(&nested);
464
465 for i in 0..50 {
470 let leaf = nested.join(format!("dir-{i}")).join("a").join("b");
471 fs::create_dir_all(&leaf).expect("create decoy tree");
472 }
473
474 let discovery = discover(&spec(vec![root_dir.clone()]));
475
476 assert_eq!(paths(&discovery), vec![outer.clone()]);
477 assert!(
481 discovery.directories_visited <= 3,
482 "expected the walk to stop at the outer boundary, visited {} directories",
483 discovery.directories_visited
484 );
485 }
486
487 #[test]
491 fn a_nested_repo_is_reached_only_by_naming_it_as_its_own_root() {
492 let dir = tempfile::tempdir().expect("temp dir");
493 let root_dir = root_of(&dir);
494 let outer = root_dir.join("outer");
495 init_repo(&outer);
496 let nested = outer.join("vendor").join("nested-repo");
497 init_repo(&nested);
498
499 let discovery = discover(&spec(vec![root_dir.clone(), nested.clone()]));
500
501 assert_eq!(paths(&discovery), {
502 let mut expected = vec![outer, nested];
503 expected.sort();
504 expected
505 });
506 }
507
508 #[test]
509 fn a_bare_repository_produces_no_row_with_no_exclusion_rule_of_its_own() {
510 let dir = tempfile::tempdir().expect("temp dir");
511 let root_dir = root_of(&dir);
512 let bare = root_dir.join("bare.git");
513 fs::create_dir_all(&bare).expect("create bare dir");
514 gix::init_bare(&bare).expect("init bare repo");
515
516 let discovery = discover(&spec(vec![root_dir.clone()]));
517
518 assert!(paths(&discovery).is_empty());
519 }
520
521 #[test]
522 fn a_vendored_checkout_inside_another_working_tree_produces_no_row() {
523 let dir = tempfile::tempdir().expect("temp dir");
524 let root_dir = root_of(&dir);
525 let outer = root_dir.join("outer");
526 init_repo(&outer);
527 let vendored = outer.join("vendor").join("some-lib");
528 init_repo(&vendored);
529
530 let discovery = discover(&spec(vec![root_dir.clone()]));
531
532 assert_eq!(paths(&discovery), vec![outer]);
533 }
534
535 #[test]
536 fn a_symlink_to_a_repo_is_followed_and_recorded() {
537 let dir = tempfile::tempdir().expect("temp dir");
538 let root_dir = root_of(&dir);
539 let target = root_dir.join("target-repo");
540 init_repo(&target);
541 let root = root_dir.join("root");
542 fs::create_dir_all(&root).expect("create root");
543 symlink(&target, root.join("link")).expect("create symlink");
544
545 let discovery = discover(&spec(vec![root]));
546
547 let canonical_target = fs::canonicalize(&target).expect("canonicalize target");
548 assert_eq!(paths(&discovery), vec![canonical_target]);
549 }
550
551 #[test]
552 fn a_symlink_to_an_ordinary_directory_is_not_followed_or_recorded() {
553 let dir = tempfile::tempdir().expect("temp dir");
554 let root_dir = root_of(&dir);
555 let target = root_dir.join("ordinary-dir");
556 fs::create_dir_all(&target).expect("create target");
557 let would_be_found = target.join("would-be-a-repo");
560 init_repo(&would_be_found);
561 let root = root_dir.join("root");
562 fs::create_dir_all(&root).expect("create root");
563 symlink(&target, root.join("link")).expect("create symlink");
564
565 let discovery = discover(&spec(vec![root]));
566
567 assert!(paths(&discovery).is_empty());
568 }
569
570 #[test]
571 fn a_self_referential_symlink_does_not_hang_or_panic() {
572 let dir = tempfile::tempdir().expect("temp dir");
573 let root_dir = root_of(&dir);
574 let root = root_dir.join("root");
575 fs::create_dir_all(&root).expect("create root");
576 symlink(root.join("loop"), root.join("loop")).expect("create self-referential symlink");
577
578 let discovery = discover(&spec(vec![root]));
579
580 assert!(paths(&discovery).is_empty());
581 }
582
583 #[test]
584 fn a_symlink_resolving_to_an_already_discovered_repo_is_dropped_silently() {
585 let dir = tempfile::tempdir().expect("temp dir");
586 let root_dir = root_of(&dir);
587 let repo = root_dir.join("repo");
588 init_repo(&repo);
589 symlink(&repo, root_dir.join("link-to-repo")).expect("create symlink");
590
591 let discovery = discover(&spec(vec![root_dir.clone()]));
592
593 assert_eq!(paths(&discovery), vec![repo]);
596 }
597
598 #[test]
603 fn glob_matching_is_case_sensitive_even_though_apfs_is_not() {
604 let dir = tempfile::tempdir().expect("temp dir");
605 let root_dir = root_of(&dir);
606 let repo = root_dir.join("Node_Modules").join("some-lib");
607 init_repo(&repo);
608
609 let mut excluding_wrong_case = spec(vec![root_dir.clone()]);
610 excluding_wrong_case.exclude = vec!["**/node_modules/**".to_string()];
611 let discovery = discover(&excluding_wrong_case);
612 assert_eq!(
613 paths(&discovery),
614 vec![repo.clone()],
615 "a lowercase exclude glob must not match a differently-cased real path"
616 );
617
618 let mut excluding_right_case = spec(vec![root_dir.clone()]);
619 excluding_right_case.exclude = vec!["**/Node_Modules/**".to_string()];
620 let discovery = discover(&excluding_right_case);
621 assert!(
622 paths(&discovery).is_empty(),
623 "an exclude glob matching the real path's exact case must match"
624 );
625 }
626
627 #[test]
628 fn an_include_glob_bounds_what_is_discovered() {
629 let dir = tempfile::tempdir().expect("temp dir");
630 let root_dir = root_of(&dir);
631 let kept = root_dir.join("kept-repo");
632 init_repo(&kept);
633 let dropped = root_dir.join("dropped-repo");
634 init_repo(&dropped);
635
636 let mut only_kept = spec(vec![root_dir.clone()]);
637 only_kept.include = vec!["**/kept-repo".to_string()];
638
639 let discovery = discover(&only_kept);
640
641 assert_eq!(paths(&discovery), vec![kept]);
642 }
643
644 #[test]
645 fn an_exclude_glob_beats_an_include_glob() {
646 let dir = tempfile::tempdir().expect("temp dir");
647 let root_dir = root_of(&dir);
648 let repo = root_dir.join("both-match");
649 init_repo(&repo);
650
651 let mut set = spec(vec![root_dir.clone()]);
652 set.include = vec!["**/both-match".to_string()];
653 set.exclude = vec!["**/both-match".to_string()];
654
655 let discovery = discover(&set);
656
657 assert!(paths(&discovery).is_empty());
658 }
659
660 #[test]
661 fn overlapping_roots_are_not_deduplicated() {
662 let dir = tempfile::tempdir().expect("temp dir");
663 let root_dir = root_of(&dir);
664 let repo = root_dir.join("repo");
665 init_repo(&repo);
666
667 let discovery = discover(&spec(vec![root_dir.clone(), repo.clone()]));
669
670 assert_eq!(discovery.entities.len(), 2);
671 assert!(paths(&discovery).iter().all(|p| p == &repo));
672 }
673
674 #[test]
679 fn the_walk_abandons_after_its_deadline_and_reports_what_it_found() {
680 let dir = tempfile::tempdir().expect("temp dir");
681 let root_dir = root_of(&dir);
682 let repo = root_dir.join("repo");
683 init_repo(&repo);
684 for i in 0..20 {
687 fs::create_dir_all(root_dir.join(format!("plain-{i}"))).expect("create plain dir");
688 }
689
690 let discovery = walk(&spec(vec![root_dir.clone()]), Duration::ZERO, None);
691
692 assert!(discovery.abandoned);
693 assert!(discovery.directories_visited >= 1);
694 }
695
696 #[test]
697 fn a_watched_walk_publishes_its_running_directory_count() {
698 let dir = tempfile::tempdir().expect("temp dir");
699 let root_dir = root_of(&dir);
700 for i in 0..5 {
701 fs::create_dir_all(root_dir.join(format!("plain-{i}"))).expect("create plain dir");
702 }
703 let progress = Arc::new(AtomicUsize::new(0));
704
705 let discovery = discover_watched_with_deadline(
706 &spec(vec![root_dir.clone()]),
707 Arc::clone(&progress),
708 ABANDON_AFTER,
709 );
710
711 assert_eq!(
715 progress.load(Ordering::Relaxed),
716 discovery.directories_visited
717 );
718 assert!(progress.load(Ordering::Relaxed) > 0);
719 }
720
721 #[test]
722 fn a_missing_root_is_not_an_error_and_finds_nothing() {
723 let dir = tempfile::tempdir().expect("temp dir");
724 let root_dir = root_of(&dir);
725 let missing = root_dir.join("does-not-exist");
726
727 let discovery = discover(&spec(vec![missing]));
728
729 assert!(paths(&discovery).is_empty());
730 assert!(!discovery.abandoned);
731 }
732
733 fn write_gitmodules(repo: &Path, name: &str, path: &str) {
736 fs::write(
737 repo.join(".gitmodules"),
738 format!(
739 "[submodule \"{name}\"]\n\tpath = {path}\n\turl = https://example.com/{name}.git\n"
740 ),
741 )
742 .expect("write .gitmodules");
743 }
744
745 fn resolved_paths(entities: &[DiscoveredEntity]) -> Vec<PathBuf> {
746 let mut paths: Vec<PathBuf> = entities
747 .iter()
748 .map(|entity| entity.key.path().to_path_buf())
749 .collect();
750 paths.sort();
751 paths
752 }
753
754 #[test]
758 fn resolve_hands_back_the_thread_safe_handle_it_already_opened() {
759 let dir = tempfile::tempdir().expect("temp dir");
760 let root_dir = root_of(&dir);
761 let repo = root_dir.join("repo");
762 init_repo(&repo);
763
764 let set = spec(vec![root_dir.clone()]);
765 let discovery = discover(&set);
766 let (entities, _) = resolve(&set, &discovery.entities);
767
768 let entity = entities
769 .iter()
770 .find(|entity| entity.key.path() == repo)
771 .expect("repo entity present");
772 let repo_handle = entity
773 .repo
774 .as_ref()
775 .expect("resolve should hand back the handle it opened");
776 assert_eq!(
779 repo_handle.to_thread_local().head_id().ok(),
780 gix::open(&repo).unwrap().head_id().ok()
781 );
782 }
783
784 #[test]
789 fn resolve_with_cache_reuses_an_already_open_handle_by_pointer_identity() {
790 let dir = tempfile::tempdir().expect("temp dir");
791 let root_dir = root_of(&dir);
792 let repo = root_dir.join("repo");
793 init_repo(&repo);
794
795 let set = spec(vec![root_dir.clone()]);
796 let discovery = discover(&set);
797 let cached = Arc::new(git::open_thread_safe(&repo).expect("open repo"));
798 let mut cache = HashMap::new();
799 cache.insert(
800 EntityKey::new(Arc::from(repo.as_path())),
801 Arc::clone(&cached),
802 );
803
804 let (entities, _) = resolve_with_cache(&set, &discovery.entities, &cache);
805
806 let entity = entities
807 .iter()
808 .find(|entity| entity.key.path() == repo)
809 .expect("repo entity present");
810 let handle = entity
811 .repo
812 .as_ref()
813 .expect("a cached boundary must still carry a handle");
814 assert!(
815 Arc::ptr_eq(handle, &cached),
816 "resolve_with_cache must hand back the very same Arc it was given, not a freshly opened one"
817 );
818 }
819
820 #[test]
824 fn resolve_with_cache_still_resolves_a_boundary_absent_from_the_cache() {
825 let dir = tempfile::tempdir().expect("temp dir");
826 let root_dir = root_of(&dir);
827 let repo = root_dir.join("repo");
828 init_repo(&repo);
829
830 let set = spec(vec![root_dir.clone()]);
831 let discovery = discover(&set);
832
833 let (entities, _) = resolve_with_cache(&set, &discovery.entities, &HashMap::new());
834
835 let entity = entities
836 .iter()
837 .find(|entity| entity.key.path() == repo)
838 .expect("repo entity present");
839 assert!(matches!(entity.kind, Kind::Repo));
840 assert!(
841 entity.repo.is_some(),
842 "an uncached boundary must still be opened and carry a handle"
843 );
844 }
845
846 #[test]
849 fn a_submodule_carries_no_cached_repository_handle() {
850 let dir = tempfile::tempdir().expect("temp dir");
851 let root_dir = root_of(&dir);
852 let outer = root_dir.join("outer");
853 init_repo(&outer);
854 write_gitmodules(&outer, "lib", "vendor/lib");
855 fs::create_dir_all(outer.join("vendor").join("lib")).expect("create submodule dir");
856
857 let set = spec(vec![root_dir.clone()]);
858 let discovery = discover(&set);
859 let (entities, _) = resolve(&set, &discovery.entities);
860
861 let submodule = entities
862 .iter()
863 .find(|entity| matches!(entity.kind, Kind::Submodule))
864 .expect("submodule entity present");
865 assert!(submodule.repo.is_none());
866 }
867
868 #[test]
873 fn a_submodules_display_name_is_its_relative_path_not_its_basename() {
874 let dir = tempfile::tempdir().expect("temp dir");
875 let root_dir = root_of(&dir);
876 let outer = root_dir.join("outer");
877 init_repo(&outer);
878 write_gitmodules(&outer, "lib", "vendor/lib");
879 fs::create_dir_all(outer.join("vendor").join("lib")).expect("create submodule dir");
880
881 let set = spec(vec![root_dir.clone()]);
882 let discovery = discover(&set);
883 let (entities, _) = resolve(&set, &discovery.entities);
884
885 let submodule = entities
886 .iter()
887 .find(|entity| matches!(entity.kind, Kind::Submodule))
888 .expect("submodule entity present");
889 assert_eq!(
890 submodule.display_name_override.as_deref(),
891 Some("vendor/lib"),
892 "expected the declared relative path, not the basename `lib`"
893 );
894
895 let parent = entities
896 .iter()
897 .find(|entity| entity.key.path() == outer)
898 .expect("parent entity present");
899 assert_eq!(
900 parent.display_name_override, None,
901 "a walked boundary keeps the generic basename fallback"
902 );
903 }
904
905 #[test]
912 fn a_submodule_is_found_by_reading_gitmodules_with_the_walk_never_descending_to_it() {
913 let dir = tempfile::tempdir().expect("temp dir");
914 let root_dir = root_of(&dir);
915 let outer = root_dir.join("outer");
916 init_repo(&outer);
917 write_gitmodules(&outer, "lib", "vendor/lib");
918
919 let submodule_dir = outer.join("vendor").join("lib");
920 for i in 0..50 {
921 let leaf = submodule_dir.join(format!("dir-{i}")).join("a").join("b");
922 fs::create_dir_all(&leaf).expect("create decoy tree inside the submodule");
923 }
924
925 let set = spec(vec![root_dir.clone()]);
926 let discovery = discover(&set);
927
928 assert!(
933 discovery.directories_visited <= 2,
934 "expected the walk to stop at the outer boundary without looking inside \
935 the submodule's directory, visited {} directories",
936 discovery.directories_visited
937 );
938
939 let (entities, failures) = resolve(&set, &discovery.entities);
940
941 assert!(failures.is_empty());
942 let submodule_path = fs::canonicalize(&submodule_dir).expect("submodule dir exists");
943 assert_eq!(resolved_paths(&entities), {
944 let mut expected = vec![outer.clone(), submodule_path.clone()];
945 expected.sort();
946 expected
947 });
948 let submodule = entities
949 .iter()
950 .find(|entity| entity.key.path() == submodule_path)
951 .expect("submodule entity present");
952 assert!(matches!(submodule.kind, Kind::Submodule));
953 let parent = entities
954 .iter()
955 .find(|entity| entity.key.path() == outer)
956 .expect("parent entity present");
957 assert!(matches!(parent.kind, Kind::Repo));
958 }
959
960 #[test]
966 fn a_submodule_is_always_returned_even_though_nothing_here_can_hide_it() {
967 let dir = tempfile::tempdir().expect("temp dir");
968 let root_dir = root_of(&dir);
969 let outer = root_dir.join("outer");
970 init_repo(&outer);
971 write_gitmodules(&outer, "lib", "vendor/lib");
972 fs::create_dir_all(outer.join("vendor").join("lib")).expect("create submodule dir");
973
974 let set = spec(vec![root_dir.clone()]);
975 let discovery = discover(&set);
976 let (entities, _) = resolve(&set, &discovery.entities);
977
978 assert!(
979 entities
980 .iter()
981 .any(|entity| matches!(entity.kind, Kind::Submodule)),
982 "a discovered Submodule must be present in the returned list with no way to suppress it"
983 );
984 }
985
986 #[test]
990 fn an_exclude_glob_covering_a_submodules_path_excludes_it() {
991 let dir = tempfile::tempdir().expect("temp dir");
992 let root_dir = root_of(&dir);
993 let outer = root_dir.join("outer");
994 init_repo(&outer);
995 write_gitmodules(&outer, "lib", "vendor/lib");
996 fs::create_dir_all(outer.join("vendor").join("lib")).expect("create submodule dir");
997
998 let mut set = spec(vec![root_dir.clone()]);
999 set.exclude = vec!["**/vendor/**".to_string()];
1000 let discovery = discover(&set);
1001
1002 let (entities, failures) = resolve(&set, &discovery.entities);
1003
1004 assert!(failures.is_empty());
1005 assert!(
1006 !entities
1007 .iter()
1008 .any(|entity| matches!(entity.kind, Kind::Submodule)),
1009 "an exclude glob covering the submodule's own path must keep it out of the result"
1010 );
1011 assert!(
1012 entities.iter().any(|entity| entity.key.path() == outer),
1013 "the exclude glob targets only the submodule's path, not its parent"
1014 );
1015 }
1016
1017 #[test]
1020 fn an_unparseable_gitmodules_file_is_reported_as_a_failure_with_no_submodule_rows() {
1021 let dir = tempfile::tempdir().expect("temp dir");
1022 let root_dir = root_of(&dir);
1023 let outer = root_dir.join("outer");
1024 init_repo(&outer);
1025 fs::write(
1027 outer.join(".gitmodules"),
1028 "[submodule \"lib\"\n\tpath = lib\n",
1029 )
1030 .expect("write malformed .gitmodules");
1031
1032 let set = spec(vec![root_dir.clone()]);
1033 let discovery = discover(&set);
1034
1035 let (entities, failures) = resolve(&set, &discovery.entities);
1036
1037 assert_eq!(failures.len(), 1);
1038 assert_eq!(failures[0].0.path(), outer);
1039 assert!(
1040 !entities
1041 .iter()
1042 .any(|entity| matches!(entity.kind, Kind::Submodule))
1043 );
1044 assert!(entities.iter().any(|entity| entity.key.path() == outer));
1045 }
1046}