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