Skip to main content

magi/
repos.rs

1//! Local repository discovery: `magi repos` and `GET /api/repos`.
2//!
3//! [`scan`] walks the roots named in `[repos] roots` for a ghq-layout
4//! checkout (`<root>/<host>/<owner>/<repo>`).
5//!
6//! # Read-only, and cheap enough to repeat
7//!
8//! Nothing here creates, deletes or writes anything, and no root is ever
9//! reached over the network - the whole point is that this is a filesystem
10//! fact about the operator's own machine. [`Cache`] exists only because a scan
11//! still means walking however many roots the operator configured on every
12//! request, and the web server should not repeat that walk on every poll. It
13//! is trusted for `[repos] scan_ttl` seconds and can always be forced with an
14//! explicit refresh. [`discover_verified`] is the one exception: it spawns
15//! `git` to confirm a candidate [`discover`] found by filesystem shape alone
16//! actually works, because a caller substituting it for an unresolved
17//! `--repo .` needs more than a plausible path before using it silently -
18//! see its own doc.
19//!
20//! # One implementation, two callers
21//!
22//! [`scan`] is the whole surface, and both `magi repos` and `GET /api/repos`
23//! (see [`crate::web`]) call it rather than each walking the filesystem in
24//! its own way. [`Cache`] wraps [`scan`] for the web server, which asks on
25//! every request; the CLI, invoked once per command, has no cache to keep.
26
27use std::collections::HashSet;
28use std::path::{Path, PathBuf};
29use std::sync::{Arc, Mutex, PoisonError};
30use std::time::{Duration, Instant};
31
32use serde::Serialize;
33
34/// One repository found under a configured root.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct Repo {
37    /// `<owner>/<repo>`, the short name an operator types or picks from a
38    /// list.
39    pub name: String,
40    /// Absolute path to the checkout.
41    pub path: PathBuf,
42}
43
44/// Scan every root for a ghq-layout checkout: `<root>/<host>/<owner>/<repo>`
45/// holding a `.git` directory.
46///
47/// A root that does not exist or cannot be read contributes nothing rather
48/// than failing the whole scan - a stale entry left in `[repos] roots` must
49/// not empty the picker for every other root. The same goes for a host or
50/// owner directory partway down: [`subdirs`] turns an unreadable directory
51/// into no children instead of an error.
52///
53/// Results are deduplicated by canonical path and sorted by name, so two
54/// roots that reach the same checkout - a symlink, or one root nested inside
55/// another - list it once.
56pub fn scan(roots: &[PathBuf]) -> Vec<Repo> {
57    let mut seen = HashSet::new();
58    let mut out = Vec::new();
59    for root in roots {
60        for host in subdirs(root) {
61            for owner in subdirs(&host) {
62                for dir in subdirs(&owner) {
63                    if !dir.join(".git").exists() {
64                        continue;
65                    }
66                    let path = dir.canonicalize().unwrap_or_else(|_| dir.clone());
67                    if !seen.insert(path.clone()) {
68                        continue;
69                    }
70                    let name = format!("{}/{}", file_name(&owner), file_name(&dir));
71                    out.push(Repo { name, path });
72                }
73            }
74        }
75    }
76    out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
77    out
78}
79
80/// Immediate subdirectories of `dir`, or none when it cannot be read.
81fn subdirs(dir: &Path) -> Vec<PathBuf> {
82    std::fs::read_dir(dir)
83        .into_iter()
84        .flatten()
85        .flatten()
86        .map(|entry| entry.path())
87        .filter(|p| p.is_dir())
88        .collect()
89}
90
91fn file_name(path: &Path) -> std::borrow::Cow<'_, str> {
92    path.file_name()
93        .map(|n| n.to_string_lossy())
94        .unwrap_or_default()
95}
96
97/// How deep [`discover`] walks below each root before giving up on a branch.
98/// Deep enough to reach both `<root>/<host>/<owner>/<repo>` (ghq's layout)
99/// and `<wt root>/<repo>/<run>/<seat>` (magi's own worktree layout, three
100/// levels below `wt` since a run id and a seat are directories in their own
101/// right, not part of a checkout's name); shallow enough that an unrelated
102/// tree sitting under a well-known root does not turn a fallback lookup into
103/// an unbounded walk.
104const DISCOVER_MAX_DEPTH: u32 = 4;
105
106/// The directories under the operator's home worth searching when a
107/// repository must be found without being named outright - see [`discover`].
108/// Not the same list `[repos] roots` scans by default (that one starts
109/// empty; nothing is scanned unless configured), because this fallback has
110/// to work with no configuration at all.
111fn well_known_roots(home: &Path) -> Vec<PathBuf> {
112    ["src/github.com", "ghq", "dev", "repos", "projects", "wt"]
113        .into_iter()
114        .map(|rel| home.join(rel))
115        .collect()
116}
117
118/// One checkout [`discover`] can offer as an answer.
119struct Candidate {
120    /// The main checkout's directory - never a linked worktree's own
121    /// directory, see [`main_checkout`].
122    path: PathBuf,
123    /// The main checkout's own directory name, what an operator would type
124    /// as a bare repo name.
125    name: String,
126    /// `<owner>/<name>`, when the checkout's parent directory looks like an
127    /// owner (i.e. the checkout sits at least two levels below a root), so a
128    /// hint naming `owner/repo` can match precisely instead of only the bare
129    /// name.
130    owner_name: Option<String>,
131}
132
133/// Walk `dir` for git checkouts up to `depth` levels down, resolving each to
134/// its main checkout (see [`main_checkout`]) and recording one [`Candidate`]
135/// per canonical path not already in `seen`.
136///
137/// A checkout found is not itself descended into - a repository nested
138/// inside another (a submodule, a vendored copy) is one repository as far as
139/// naming it goes, not several - and an unreadable directory contributes
140/// nothing, the same as [`subdirs`] everywhere else in this module.
141fn collect(dir: &Path, depth: u32, seen: &mut HashSet<PathBuf>, out: &mut Vec<Candidate>) {
142    if depth == 0 {
143        return;
144    }
145    for child in subdirs(dir) {
146        match main_checkout(&child) {
147            Some(main) => {
148                let path = main.canonicalize().unwrap_or(main);
149                if seen.insert(path.clone()) {
150                    let name = file_name(&path).into_owned();
151                    let owner_name = path
152                        .parent()
153                        .and_then(|p| p.file_name())
154                        .map(|owner| format!("{}/{name}", owner.to_string_lossy()));
155                    out.push(Candidate {
156                        path,
157                        name,
158                        owner_name,
159                    });
160                }
161            }
162            None => collect(&child, depth - 1, seen, out),
163        }
164    }
165}
166
167/// The main checkout `dir` belongs to, when `dir` holds a `.git` at all - a
168/// directory for a normal checkout, or the file git leaves in a linked
169/// worktree, naming its main checkout's git dir as
170/// `gitdir: <main>/.git/worktrees/<id>`.
171///
172/// Always the *main* checkout, never the worktree itself: magi's own
173/// `<wt root>/<repo>/<run>/<seat>` is a disposable seat, and matching a hint,
174/// or an operator's own repository, against a seat id instead of `<repo>`
175/// would never succeed. This is also what makes a normal checkout and one of
176/// its own linked worktrees collapse to a single [`Candidate`] in [`collect`]
177/// rather than competing as if they were two different repositories.
178fn main_checkout(dir: &Path) -> Option<PathBuf> {
179    let dot_git = dir.join(".git");
180    if dot_git.is_dir() {
181        return Some(dir.to_path_buf());
182    }
183    let contents = std::fs::read_to_string(&dot_git).ok()?;
184    let gitdir = contents.strip_prefix("gitdir:")?.trim();
185    // `<main>/.git/worktrees/<id>` -> `<main>/.git/worktrees` -> `<main>/.git`.
186    let git_dir = PathBuf::from(gitdir).parent()?.parent()?.to_path_buf();
187    if git_dir.file_name()?.to_str()? != ".git" {
188        return None;
189    }
190    Some(git_dir.parent()?.to_path_buf())
191}
192
193/// Whether `hint` mentions `token` as a whole word. `/`, `-` and `_` count as
194/// part of a word, so `owner/repo` and `my-repo` match as themselves rather
195/// than splitting into several shorter words that could each match too
196/// loosely.
197fn mentions(hint_lower: &str, token: &str) -> bool {
198    let token_lower = token.to_lowercase();
199    hint_lower
200        .split(|c: char| !(c.is_alphanumeric() || matches!(c, '/' | '-' | '_')))
201        .any(|word| word == token_lower)
202}
203
204/// One tier of [`discover`]'s matching ladder: exactly one candidate settles
205/// it, none moves on to the next tier, and more than one is refused outright
206/// rather than loosened to a lower tier - a tie at the tier that was
207/// supposed to decide it is exactly the ambiguity [`discover`] exists to not
208/// guess through.
209enum Tier {
210    Settled(PathBuf),
211    Ambiguous,
212    Miss,
213}
214
215fn tier<'a>(mut it: impl Iterator<Item = &'a Candidate>) -> Tier {
216    match (it.next(), it.next()) {
217        (None, _) => Tier::Miss,
218        (Some(only), None) => Tier::Settled(only.path.clone()),
219        (Some(_), Some(_)) => Tier::Ambiguous,
220    }
221}
222
223/// What [`discover`] found, and in a sentence, why - so a caller that
224/// substitutes it for `--repo .` can say so out loud instead of silently
225/// swapping in a different repository from the one the operator's own
226/// working directory suggested.
227pub struct Found {
228    /// The main checkout's absolute path.
229    pub path: PathBuf,
230    /// Which tier of the matching ladder settled it, in words fit to print
231    /// straight after "using `<path>` instead - ".
232    pub reason: &'static str,
233}
234
235/// Find the repository a `--repo .` most likely means when the operator's
236/// own working directory is not a git checkout at all: search well-known
237/// project directories under `home` (see [`well_known_roots`]), plus
238/// `extra_roots` (ordinarily `[repos] roots`), for a checkout matching
239/// `hint` - free-form text such as an instruction or a task body - or,
240/// failing that, this binary's own checkout (`self_name`; see
241/// `crate::updater::REPO`, the one place that name is defined).
242///
243/// A checkout is used only when it is the single best match at whichever
244/// tier of the ladder settles it: an `owner/repo` mention in `hint`, then a
245/// bare name mention, then `self_name` alone with nothing else sharing it.
246/// `None` either means nothing at all was found, or that a tier which would
247/// otherwise have decided it saw more than one candidate - both are left for
248/// the caller to fall back to asking the operator, the same as a miss or an
249/// ambiguous match through [`crate::main`]'s `resolve_repo_by_name` (see its
250/// own doc for why silently guessing between several checkouts is worse than
251/// the round trip this exists to save).
252pub fn discover(
253    home: &Path,
254    extra_roots: &[PathBuf],
255    hint: Option<&str>,
256    self_name: &str,
257) -> Option<Found> {
258    let mut roots = well_known_roots(home);
259    roots.extend(extra_roots.iter().cloned());
260
261    let mut seen = HashSet::new();
262    let mut candidates = Vec::new();
263    for root in &roots {
264        collect(root, DISCOVER_MAX_DEPTH, &mut seen, &mut candidates);
265    }
266
267    if let Some(hint) = hint {
268        let hint_lower = hint.to_lowercase();
269        match tier(candidates.iter().filter(|c| {
270            c.owner_name
271                .as_deref()
272                .is_some_and(|on| mentions(&hint_lower, on))
273        })) {
274            Tier::Settled(path) => {
275                return Some(Found {
276                    path,
277                    reason: "its owner/repo name is mentioned in the hint",
278                });
279            }
280            Tier::Ambiguous => return None,
281            Tier::Miss => {}
282        }
283        match tier(candidates.iter().filter(|c| mentions(&hint_lower, &c.name))) {
284            Tier::Settled(path) => {
285                return Some(Found {
286                    path,
287                    reason: "its name is mentioned in the hint",
288                });
289            }
290            Tier::Ambiguous => return None,
291            Tier::Miss => {}
292        }
293    }
294
295    match tier(candidates.iter().filter(|c| c.name == self_name)) {
296        Tier::Settled(path) => Some(Found {
297            path,
298            reason: "it is this binary's own repository, the only checkout found under the \
299                     usual project directories",
300        }),
301        Tier::Ambiguous | Tier::Miss => None,
302    }
303}
304
305/// [`discover`], but verified: a checkout is only returned once
306/// `crate::git::toplevel` confirms it actually works there. A directory that
307/// merely *has* a `.git` - a stale entry, a `git init` interrupted before it
308/// wrote anything past the directory itself, a linked worktree whose main
309/// checkout was since deleted - is not a confident match, and neither is a
310/// checkout found while the local git installation itself is broken: either
311/// way the caller must fall back to asking the operator exactly like a miss,
312/// not silently accept a path this module only ever inspected as bytes on
313/// disk.
314///
315/// The one function in this module that spawns anything - everything else
316/// here is the filesystem walk described in the module doc - because a
317/// plausible path is not the same claim as a working repository, and the
318/// callers this exists for (a default `--repo .` that already failed its
319/// own `git::toplevel` check) exist precisely because that distinction
320/// matters.
321pub async fn discover_verified(
322    home: &Path,
323    extra_roots: &[PathBuf],
324    hint: Option<&str>,
325    self_name: &str,
326) -> Option<Found> {
327    let found = discover(home, extra_roots, hint, self_name)?;
328    crate::git::toplevel(&found.path).await.ok()?;
329    Some(found)
330}
331
332/// In-process cache of the last scan.
333///
334/// `Arc<Mutex<..>>` inside rather than deriving over a bare `Mutex`, so
335/// `Cache` itself is cheap to clone - [`crate::web::Ui`] clones its shared
336/// state the same way for its loop and turn-guard bookkeeping.
337#[derive(Debug, Clone, Default)]
338pub struct Cache {
339    state: Arc<Mutex<State>>,
340}
341
342#[derive(Debug, Default)]
343struct State {
344    repos: Vec<Repo>,
345    scanned_at: Option<Instant>,
346}
347
348impl Cache {
349    /// An empty cache. The first [`Cache::list`] always scans.
350    pub fn new() -> Self {
351        Self::default()
352    }
353
354    /// The repositories under `roots`, rescanning when `refresh` is set, the
355    /// cache has never been filled, `ttl` has elapsed, or `ttl` is zero -
356    /// which means "never trust the cache".
357    pub fn list(&self, roots: &[PathBuf], ttl: Duration, refresh: bool) -> Vec<Repo> {
358        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
359        let stale =
360            refresh || ttl.is_zero() || state.scanned_at.is_none_or(|at| at.elapsed() >= ttl);
361        if stale {
362            state.repos = scan(roots);
363            state.scanned_at = Some(Instant::now());
364        }
365        state.repos.clone()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    /// Builds `<root>/<host>/<owner>/<repo>`, with a `.git` directory only
374    /// when `git` is true - the one thing that makes a directory count.
375    fn make(root: &Path, host: &str, owner: &str, repo: &str, git: bool) -> PathBuf {
376        let dir = root.join(host).join(owner).join(repo);
377        std::fs::create_dir_all(&dir).expect("create repo dir");
378        if git {
379            std::fs::create_dir_all(dir.join(".git")).expect("create .git");
380        }
381        dir
382    }
383
384    #[test]
385    fn scan_finds_only_git_checkouts_deduplicated_and_sorted_by_name() {
386        let tmp = tempfile::tempdir().expect("tempdir");
387        let root = tmp.path().to_owned();
388        make(&root, "github.com", "yukimemi", "rvpm", true);
389        make(&root, "github.com", "yukimemi", "magi", true);
390        // No `.git`: a checkout that has not been cloned, or any other
391        // directory that happens to sit at the right depth.
392        make(&root, "github.com", "yukimemi", "not-a-checkout", false);
393
394        let repos = scan(&[root]);
395        let names: Vec<&str> = repos.iter().map(|r| r.name.as_str()).collect();
396        assert_eq!(names, ["yukimemi/magi", "yukimemi/rvpm"]);
397        assert!(repos.iter().all(|r| r.path.is_absolute()));
398    }
399
400    #[test]
401    fn a_missing_root_does_not_empty_the_results_of_the_others() {
402        let tmp = tempfile::tempdir().expect("tempdir");
403        let good = tmp.path().join("good");
404        std::fs::create_dir_all(&good).expect("good root");
405        make(&good, "github.com", "yukimemi", "magi", true);
406        let missing = tmp.path().join("does-not-exist");
407
408        let repos = scan(&[missing, good]);
409        assert_eq!(repos.len(), 1);
410        assert_eq!(repos[0].name, "yukimemi/magi");
411    }
412
413    #[test]
414    fn duplicate_paths_across_roots_are_counted_once() {
415        let tmp = tempfile::tempdir().expect("tempdir");
416        let root = tmp.path().to_owned();
417        make(&root, "github.com", "yukimemi", "magi", true);
418
419        // The same root named twice is the simplest way to exercise the
420        // dedup path without touching symlinks, which are not portable to
421        // set up in a test.
422        let repos = scan(&[root.clone(), root]);
423        assert_eq!(repos.len(), 1);
424    }
425
426    #[test]
427    fn the_cache_does_not_rescan_within_the_ttl_but_refresh_forces_it() {
428        let tmp = tempfile::tempdir().expect("tempdir");
429        let root = tmp.path().to_owned();
430        make(&root, "github.com", "yukimemi", "magi", true);
431        let roots = [root.clone()];
432        let cache = Cache::new();
433
434        let first = cache.list(&roots, Duration::from_secs(3600), false);
435        assert_eq!(first.len(), 1);
436
437        // A repository appears after the first scan; within the TTL the
438        // cached answer must not notice it.
439        make(&root, "github.com", "yukimemi", "rvpm", true);
440        let second = cache.list(&roots, Duration::from_secs(3600), false);
441        assert_eq!(second.len(), 1, "a fresh cache must not rescan");
442
443        let refreshed = cache.list(&roots, Duration::from_secs(3600), true);
444        assert_eq!(refreshed.len(), 2, "an explicit refresh must rescan");
445
446        // The TTL now has to be honoured again against the refreshed scan.
447        make(&root, "github.com", "yukimemi", "third", true);
448        let still_cached = cache.list(&roots, Duration::from_secs(3600), false);
449        assert_eq!(still_cached.len(), 2);
450    }
451
452    #[test]
453    fn a_zero_ttl_always_rescans() {
454        let tmp = tempfile::tempdir().expect("tempdir");
455        let root = tmp.path().to_owned();
456        make(&root, "github.com", "yukimemi", "magi", true);
457        let roots = [root.clone()];
458        let cache = Cache::new();
459
460        assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 1);
461        make(&root, "github.com", "yukimemi", "rvpm", true);
462        assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 2);
463    }
464
465    #[test]
466    fn main_checkout_resolves_a_linked_worktree_to_its_main_checkout() {
467        let tmp = tempfile::tempdir().expect("tempdir");
468        let main = tmp.path().join("main-repo");
469        std::fs::create_dir_all(main.join(".git").join("worktrees").join("seat"))
470            .expect("create main .git/worktrees/seat");
471        let worktree = tmp.path().join("wt-repo");
472        std::fs::create_dir_all(&worktree).expect("create worktree dir");
473        std::fs::write(
474            worktree.join(".git"),
475            format!(
476                "gitdir: {}\n",
477                main.join(".git").join("worktrees").join("seat").display()
478            ),
479        )
480        .expect("write .git file");
481
482        assert_eq!(main_checkout(&worktree), Some(main));
483    }
484
485    #[test]
486    fn main_checkout_is_none_without_a_git_dir_or_file() {
487        let tmp = tempfile::tempdir().expect("tempdir");
488        assert_eq!(main_checkout(tmp.path()), None);
489    }
490
491    #[test]
492    fn discover_finds_this_binarys_own_repository_under_a_well_known_root_with_no_hint() {
493        let tmp = tempfile::tempdir().expect("tempdir");
494        let home = tmp.path();
495        let repo = home
496            .join("src")
497            .join("github.com")
498            .join("yukimemi")
499            .join("magi");
500        std::fs::create_dir_all(repo.join(".git")).expect("create repo .git");
501
502        let found = discover(home, &[], None, "magi").expect("self-name match");
503        assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
504        assert!(
505            found.reason.contains("own repository"),
506            "got: {}",
507            found.reason
508        );
509    }
510
511    #[test]
512    fn discover_finds_nothing_when_no_checkout_matches_self_name() {
513        let tmp = tempfile::tempdir().expect("tempdir");
514        let home = tmp.path();
515        std::fs::create_dir_all(home.join("dev").join("yukimemi").join("other").join(".git"))
516            .expect("create unrelated repo");
517
518        assert!(discover(home, &[], None, "magi").is_none());
519    }
520
521    #[test]
522    fn discover_prefers_an_owner_repo_hint_over_a_bare_name_collision() {
523        let tmp = tempfile::tempdir().expect("tempdir");
524        let home = tmp.path();
525        let mine = home.join("dev").join("yukimemi").join("widget");
526        let theirs = home.join("dev").join("someoneelse").join("widget");
527        std::fs::create_dir_all(mine.join(".git")).expect("create mine");
528        std::fs::create_dir_all(theirs.join(".git")).expect("create theirs");
529
530        let found = discover(
531            home,
532            &[],
533            Some("please fix a bug in yukimemi/widget"),
534            "magi",
535        )
536        .expect("owner/repo hint resolves the tie");
537        assert_eq!(found.path, mine.canonicalize().expect("canonicalize mine"));
538        assert!(found.reason.contains("owner/repo"), "got: {}", found.reason);
539    }
540
541    #[test]
542    fn discover_matches_a_unique_bare_name_mentioned_in_the_hint() {
543        let tmp = tempfile::tempdir().expect("tempdir");
544        let home = tmp.path();
545        let repo = home.join("repos").join("gizmo");
546        std::fs::create_dir_all(repo.join(".git")).expect("create repo");
547
548        let found = discover(home, &[], Some("look at gizmo please"), "magi")
549            .expect("bare name hint resolves");
550        assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
551        assert!(
552            found.reason.contains("name is mentioned"),
553            "got: {}",
554            found.reason
555        );
556    }
557
558    #[test]
559    fn discover_refuses_a_bare_name_hint_shared_by_two_checkouts_rather_than_guessing() {
560        let tmp = tempfile::tempdir().expect("tempdir");
561        let home = tmp.path();
562        std::fs::create_dir_all(
563            home.join("dev")
564                .join("yukimemi")
565                .join("widget")
566                .join(".git"),
567        )
568        .expect("create first widget");
569        std::fs::create_dir_all(
570            home.join("dev")
571                .join("someoneelse")
572                .join("widget")
573                .join(".git"),
574        )
575        .expect("create second widget");
576
577        // A tie at the bare-name tier is refused outright, not loosened to
578        // the self-name tier even though "magi" matches neither.
579        assert!(discover(home, &[], Some("please fix widget"), "magi").is_none());
580    }
581
582    #[test]
583    fn discover_resolves_a_worktree_under_wt_to_its_main_checkout() {
584        let tmp = tempfile::tempdir().expect("tempdir");
585        let home = tmp.path();
586        // The main checkout lives outside every well-known root; only the
587        // worktree pointer under `wt` leads back to it, the same as magi's
588        // own `<wt root>/<repo>/<run>/<seat>` layout for its own checkout.
589        let main = tmp.path().join("elsewhere").join("magi");
590        std::fs::create_dir_all(main.join(".git").join("worktrees").join("cand-A"))
591            .expect("create main .git/worktrees/cand-A");
592
593        let seat = home.join("wt").join("magi").join("b21f").join("cand-A");
594        std::fs::create_dir_all(&seat).expect("create seat dir");
595        std::fs::write(
596            seat.join(".git"),
597            format!(
598                "gitdir: {}\n",
599                main.join(".git").join("worktrees").join("cand-A").display()
600            ),
601        )
602        .expect("write worktree .git file");
603
604        let found = discover(home, &[], None, "magi").expect("self-name match via worktree");
605        assert_eq!(found.path, main.canonicalize().expect("canonicalize main"));
606    }
607
608    #[tokio::test]
609    async fn discover_verified_refuses_a_directory_whose_git_dir_is_not_a_real_checkout() {
610        let tmp = tempfile::tempdir().expect("tempdir");
611        let home = tmp.path();
612        // `discover` alone is satisfied by a directory that merely has a
613        // `.git` - a stale entry, or a `git init` that never got past making
614        // the directory. `discover_verified` must catch what the bare
615        // filesystem shape cannot: `git` itself refuses to treat this as a
616        // working tree.
617        std::fs::create_dir_all(home.join("repos").join("widget").join(".git"))
618            .expect("create a .git directory with nothing real inside it");
619
620        assert!(
621            discover_verified(home, &[], None, "widget").await.is_none(),
622            "a `.git` directory that is not an actual checkout must not be returned"
623        );
624    }
625
626    #[tokio::test]
627    async fn discover_verified_accepts_a_real_checkout() {
628        let tmp = tempfile::tempdir().expect("tempdir");
629        let home = tmp.path();
630        let repo = home.join("repos").join("widget");
631        tokio::fs::create_dir_all(&repo)
632            .await
633            .expect("create repo dir");
634        crate::git::git(&repo, &["init", "-b", "main"])
635            .await
636            .expect("git init");
637
638        let found = discover_verified(home, &[], None, "widget")
639            .await
640            .expect("a real checkout resolves");
641        assert_eq!(found.path, repo.canonicalize().expect("canonicalize repo"));
642    }
643}