Skip to main content

repon_core/
discovery.rs

1//! Discovery's two halves: the boundary-stop walk, and turning what it found into
2//! typed entities.
3//!
4//! See [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)
5//! and [ADR 0017](https://github.com/paulchiu/repon/blob/main/docs/adr/0017-discovery-stops-at-the-repo-boundary.md).
6//! [`discover`] finds Repo boundaries and bounds them by a Set's globs; [`resolve`]
7//! turns each boundary into a Repo or a Worktree and reads its `.gitmodules` one
8//! level deep to add its Submodules, testing each Submodule's own path against the
9//! same globs. Discovery returns one combined entity list either way: nothing
10//! records which half produced a given entry.
11
12use 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/// A Set's bounding specification, handed to the core as plain data: no TOML type, no file
23/// path, no `~` expansion left to do. [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)
24/// keeps the file format on the consumer's side; this is what crosses the boundary.
25#[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/// What one discovery walk found.
34#[derive(Debug, Clone)]
35pub struct Discovery {
36    /// Every Repo boundary the walk reached, bounded by the Set's globs. Two roots that
37    /// reach the same boundary both contribute it: nothing here deduplicates across roots,
38    /// which is what leaves a deliberately nested root as the only way to reach a
39    /// repository sitting inside another repository's working tree.
40    pub entities: Vec<EntityKey>,
41    /// Directories visited, counted inline during the single pass rather than in a
42    /// separate pre-count.
43    pub directories_visited: usize,
44    /// Set once the walk has run for thirty seconds; `entities` holds whatever the walk
45    /// found before giving up.
46    pub abandoned: bool,
47}
48
49/// The walk gives up and reports what it found rather than run unbounded against a
50/// misconfigured root; [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md)
51/// fixes this at thirty seconds. `pub(crate)` so `Core` can hand the same real
52/// deadline to every discovery invocation it makes, at `start` and at the head of
53/// every later Generation alike, and so a test can inject a shorter one instead.
54pub(crate) const ABANDON_AFTER: Duration = Duration::from_secs(30);
55
56/// Walks every root in `spec`, stopping at each Repo boundary, and returns the bounded
57/// list. Never descends into a boundary and never descends through a symlink, so a cycle
58/// cannot form by descent and needs no visited set or cycle detector to guard against; the
59/// set this module does keep exists only to give a symlink's target the same identity as
60/// its real name, never to remember a path already walked. Never reads or writes a cache.
61pub fn discover(spec: &SetSpec) -> Discovery {
62    walk(spec, ABANDON_AFTER, None)
63}
64
65/// Runs the same walk as [`discover`], but publishes the running directory count to
66/// `progress` as it goes.
67///
68/// Discovery itself has no callback and no notion of "still running": it returns once,
69/// at the end. This is the seam that lets something outside the walk, namely
70/// `Core::start`'s dedicated thread, watch an in-flight walk and warn once it has run
71/// for a second without finishing, per
72/// [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md).
73/// The abandon deadline is injected rather than always the real thirty seconds,
74/// so a test can force abandonment deterministically instead of waiting it out.
75/// `Core` calls this at the head of every Generation it re-runs discovery for
76/// (at `start`, and again at the head of every later `refresh`), always with the
77/// real [`ABANDON_AFTER`] outside a test.
78pub(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
86/// Matches a Set against the boundary-stop walk with no probing and no provenance,
87/// for `repon sets` to print a count per declared Set. Infallible, like [`discover`]
88/// itself: an unreadable or missing root is silently zero entities rather than an
89/// error, so there is no `DiscoveryError` for this to return.
90pub fn count(spec: &SetSpec) -> usize {
91    discover(spec).entities.len()
92}
93
94/// One Repo or Worktree boundary [`discover`] found, or a Submodule named by one
95/// of their own `.gitmodules` files: discovery's two halves return one list built
96/// from entries shaped like this, with nothing recording which half produced a
97/// given entry.
98#[derive(Debug, Clone)]
99pub(crate) struct DiscoveredEntity {
100    pub key: EntityKey,
101    pub kind: Kind,
102    pub common_dir: Arc<Path>,
103    /// The thread-safe handle `git::resolve_boundary` already opened to answer
104    /// `kind` and `common_dir`, kept so the entity's first phase A probe derives
105    /// its own `Repository` from this instead of opening the repository again.
106    /// `None` for a Submodule, which `resolve` names from `.gitmodules` without
107    /// opening it, and for a boundary that would not even open in the first place.
108    pub repo: Option<Arc<gix::ThreadSafeRepository>>,
109    /// The name to show, when it must be something other than the generic
110    /// basename-of-absolute-path every walked boundary gets: a Submodule's own
111    /// relative path as `.gitmodules` declares it (`vendor/lib`, never merely
112    /// `lib`), per [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
113    /// "The Submodule row" ("`name` | the submodule path"). `None` for a walked
114    /// boundary, which keeps the generic fallback.
115    pub display_name_override: Option<Arc<str>>,
116}
117
118/// Discovery's second half: resolves every boundary's own Kind (Repo or Worktree)
119/// and common dir, then reads its `.gitmodules` one level deep, never recursing,
120/// to add its Submodules. Each Submodule's own absolute path is tested against
121/// `spec`'s globs exactly as a walked path is
122/// ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#sets)),
123/// even though it reaches the Set from `.gitmodules` rather than from the walk.
124///
125/// A boundary whose `.gitmodules` exists but will not read or parse contributes no
126/// Submodule rows for it and is named in the second, returned list, keyed by its
127/// own key with the failure message; that boundary's own entity is still returned
128/// untouched; the sole authority for what is a Submodule stays the file, never a
129/// gitlink read from the index.
130pub(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
137/// [`resolve`], but reusing an already-open handle for a boundary `cache` already
138/// holds one for, rather than opening it again via `gix::open`. This is what lets
139/// discovery re-run every Generation ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md))
140/// without paying every boundary's open cost again each time: only a boundary
141/// `cache` has no entry for (a genuinely new one) is opened fresh. A boundary
142/// resolved from the cache hands back the same `Arc` `cache` gave it, never a new
143/// one, so a caller can tell reuse happened by pointer identity.
144pub(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        // A cache hit and a fresh open both end up with a real `Resolved`, just
156        // reached differently; both are folded into this one `Result`, carrying
157        // the cached handle alongside when there is one, so its fields are read
158        // out in the single match arm below, the one place a field added to
159        // `Resolved` would need to start being threaded through here.
160        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            // A boundary the walk just found that will not even open is treated as
180            // an ordinary Repo with no Submodules rather than dropped: an opaque
181            // git-open failure surfaces later, on the branch probe that already
182            // reports it, not by discovery silently shrinking its own result.
183            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                    // A Submodule's own git dir, once initialised, lives at
209                    // `<parent common dir>/modules/<name>`
210                    // ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#per-repo-entries)),
211                    // never the parent's own common dir.
212                    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
232/// A directory is a boundary when it holds a `.git` entry, file or directory form alike.
233fn is_boundary(dir: &Path) -> bool {
234    dir.join(".git").exists()
235}
236
237/// A compiled include/exclude pair. An unparsable pattern matches nothing rather than
238/// panicking: rejecting a bad glob is the config loader's failure grade, not this walk's.
239struct 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    /// Case-sensitive against the absolute path, per
260    /// [config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#sets):
261    /// `globset::Glob` is case-sensitive unless asked otherwise, and this never asks.
262    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    // Populated by every ordinary (non-symlink) boundary hit, canonicalized. Ordinary hits
273    // are never checked against it before being recorded, which is what lets two
274    // overlapping roots report the same boundary twice with no suppression getting in the
275    // way; it exists so symlinks, resolved afterwards, can tell a Repo already found under
276    // its real name from one they alone would discover.
277    let mut discovered_by_walk: HashSet<PathBuf> = HashSet::new();
278    // Directory symlinks are collected rather than resolved as they are met, so that every
279    // real name the ordinary walk will find is already in `discovered_by_walk` by the time
280    // a symlink is checked against it: the dedup in `resolve_symlink` would otherwise depend
281    // on the arbitrary order a directory's entries happen to arrive in.
282    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                // Canonicalized so identity agrees with a symlink target resolving to this
301                // same boundary by a different route (for example, a temporary directory
302                // itself reached through a symlinked ancestor on macOS).
303                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
343/// A directory symlink is followed only far enough to see whether its target is itself a
344/// Repo, and then only to record that Repo: the walk never descends through it, so a cycle
345/// (including a self-referential symlink) cannot form by descent. `fs::canonicalize`
346/// resolving an excessive symlink chain returns an error, which this treats the same as an
347/// unreadable path: skipped, not a panic and not a hang.
348fn 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        // The target is not itself a Repo: this is the "symlink to a directory of Repos"
365        // case, never followed, and the escape hatch is a root, not this walk.
366        return;
367    }
368    if discovered_by_walk.contains(&target) {
369        // Already discovered under its real name: dropped silently, no warning, because
370        // identity is the canonical path and nothing is wrong.
371        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    /// A temp dir's own path canonicalized once, so every path built from it already
399    /// agrees with the canonical form discovery reports (macOS routes `/tmp` and
400    /// `/var/folders` through a symlink, which a raw comparison would trip over).
401    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    /// `count` matches a Set against real, disposable repositories, each with a real commit
429    /// (so a probe finding one has something to actually read), plus a plain directory that
430    /// must not be counted. The number being right is only half the claim: the `repon` crate's
431    /// own `sets.rs` scans this file's production source for `Cell` and `EntityState`, which
432    /// is what proves nothing here ever probes one of these repositories to get this number.
433    #[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    /// The defining behaviour: the walk must never descend into a directory once it is
449    /// recognised as a Repo. A count of discovered entities can pass this test while still
450    /// walking everything underneath, so this asserts the stop itself: a directory placed
451    /// deep inside the outer Repo's working tree, alongside a nested inner Repo, is never
452    /// visited, proven by the directories-visited count staying far below what a full walk
453    /// of the tree would touch.
454    #[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        // A nested Repo, deliberately deep, sitting inside the outer Repo's working tree.
462        let nested = outer.join("vendor").join("nested-repo");
463        init_repo(&nested);
464
465        // A wide, deep fan of plain directories inside the nested Repo's own working tree.
466        // If the walk ever descended past the outer boundary, it would visit every one of
467        // these; if it stops at `outer`, it visits none of them, and the visited count
468        // proves which happened rather than merely asserting the final entity count.
469        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        // Popped from the stack: the temp root, `outer` itself, and nothing past it. If the
478        // walk had descended into `outer`'s working tree it would have visited `vendor`,
479        // `nested-repo` and the 150 decoy directories on top of these two.
480        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    /// A repository deliberately nested inside another's working tree is reached only by
488    /// naming its own directory as a root; discovery.md records this as the sole escape
489    /// hatch, and it does not dedup roots to get in the way.
490    #[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        // Give the walk something to find only by descending through the symlink, so a
558        // bug that follows it anyway produces a visible false positive.
559        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        // Two paths reach the one Repo (its real name, and the symlink); only its real
594        // name is reported, per identity being the canonical path.
595        assert_eq!(paths(&discovery), vec![repo]);
596    }
597
598    /// Globs match case-sensitively against the absolute path, deliberately including on a
599    /// case-insensitive filesystem (APFS): a glob that differs from the real path only in
600    /// case must not match, which is the case a test that only runs on such a filesystem
601    /// would silently rely on rather than prove.
602    #[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        // Two roots both reach the same Repo: the outer temp dir, and the Repo's own path.
668        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    /// Real-clock 30-second abandonment is exercised through the private `walk` entry
675    /// point with a near-zero deadline, rather than by waiting 30 real seconds for the
676    /// public constant: this proves the abandon-and-report-partial-results behaviour
677    /// without an artificially slow test.
678    #[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        // A directory with many entries after the boundary, so a deadline of zero is
685        // guaranteed to trip before the walk would otherwise finish on its own.
686        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        // The walk has finished, so progress should have been updated at least once and
712        // land on the same final count discovery itself reports; a stub that never wrote
713        // through the atomic would leave `progress` at zero.
714        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    /// Hand-writes a `.gitmodules` file naming one submodule, rather than running
734    /// `git submodule add` against a real remote, so the fixture stays hermetic.
735    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    /// `resolve` opens each boundary once and hands the resulting thread-safe
755    /// handle back rather than discarding it, so a later phase A probe can derive
756    /// its own `Repository` from it instead of opening the repository again.
757    #[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        // Proof it is a working handle, not a placeholder: deriving a `Repository`
777        // from it and opening it once more independently agree on the same HEAD.
778        assert_eq!(
779            repo_handle.to_thread_local().head_id().ok(),
780            gix::open(&repo).unwrap().head_id().ok()
781        );
782    }
783
784    /// The defining behaviour that lets discovery re-run every Generation without
785    /// paying every entity's open cost again: a boundary `resolve_with_cache`
786    /// already has a handle for comes back with that exact same `Arc`, proven by
787    /// pointer identity rather than by two handles merely agreeing on `HEAD`.
788    #[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    /// The other half: a boundary with no entry in `cache` is still resolved
821    /// correctly, exactly as a plain [`resolve`] call would, so an empty or
822    /// partial cache never drops or misreads a boundary.
823    #[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    /// A Submodule is named from `.gitmodules` without discovery ever opening it,
847    /// so it carries no cached handle for a later probe to reuse.
848    #[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    /// A Submodule's name is its own relative path as `.gitmodules` declares it, not the
869    /// basename every walked boundary falls back to: [discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md)'s
870    /// "The Submodule row" fixes `name` as "the submodule path", and `vendor/lib`'s basename
871    /// alone, `lib`, would lose exactly the prefix that makes the name legible.
872    #[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    /// The defining behaviour: a Submodule is found by reading its parent's
906    /// `.gitmodules`, never by the walk descending into the parent's working tree.
907    /// The submodule's own directory holds decoy subdirectories the walk would
908    /// have visited had it ever looked, so a low `directories_visited` count and
909    /// the Submodule entity still turning up together prove reading rather than
910    /// walking, rather than merely asserting the final entity count.
911    #[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        // Only the temp root and `outer` itself are ever visited: the walk stops
929        // the instant it sees `outer`'s `.git` and never looks inside its working
930        // tree, so the 50 decoy directories under the submodule's own path are
931        // never touched.
932        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    /// Submodules are hidden by default in the TUI (ADR 0009), but this crate has
961    /// no notion of "shown" at all: `resolve` takes no flag that could suppress a
962    /// Submodule, so one it finds is always part of the returned entity list.
963    /// Hiding is a consumer-only display concern layered on top of what this
964    /// crate always discovers ([discovery.md](https://github.com/paulchiu/repon/blob/main/docs/spec/discovery.md#showing-submodules)).
965    #[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    /// A Submodule's own absolute path is tested against a Set's globs the same
987    /// way a walked path is, even though it reaches the Set from `.gitmodules`
988    /// rather than from the walk ([config.md](https://github.com/paulchiu/repon/blob/main/docs/spec/config.md#sets)).
989    #[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    /// A `.gitmodules` that will not parse marks its parent as failed and yields
1018    /// no Submodule rows for it, without dropping the parent's own entity.
1019    #[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        // An unterminated section header: not valid git-config syntax.
1026        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}