Skip to main content

magi/
repos.rs

1//! Local repository discovery for the plan surface's repository picker.
2//!
3//! `magi plan` and the browser interview both start against one repository -
4//! whatever `--repo` or `magi web --repo` named at startup - and until now
5//! that was also the only repository either surface could ever reach. This
6//! module is what lets an operator name a *different* checkout without
7//! typing its full path: [`scan`] walks the roots named in `[repos] roots`
8//! for a ghq-layout checkout (`<root>/<host>/<owner>/<repo>`), and [`resolve`]
9//! turns a short name like `yukimemi/rvpm` into the one path it names.
10//!
11//! # Read-only, and cheap enough to repeat
12//!
13//! Nothing here creates, deletes or writes anything, and no root is ever
14//! reached over the network - the whole point is that this is a filesystem
15//! fact about the operator's own machine. [`Cache`] exists only because a scan
16//! still means walking however many roots the operator configured on every
17//! request, and a phone re-opening the "start a conversation" panel should
18//! not repeat that walk every time. It is trusted for `[repos] scan_ttl`
19//! seconds and can always be forced with an explicit refresh.
20//!
21//! # One implementation, two callers
22//!
23//! [`scan`] and [`resolve`] are the whole surface, and both `magi repos` /
24//! `magi plan --repo` (see [`crate::plan`]) and `GET /api/repos` (see
25//! [`crate::web`]) call them rather than each walking the filesystem in its
26//! own way. [`Cache`] wraps [`scan`] for the web server, which asks on every
27//! request; the CLI, invoked once per command, has no cache to keep.
28
29use std::collections::HashSet;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, Mutex, PoisonError};
32use std::time::{Duration, Instant};
33
34use anyhow::{Result, bail};
35use serde::Serialize;
36
37/// One repository found under a configured root.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct Repo {
40    /// `<owner>/<repo>`, the short name an operator types or picks from a
41    /// list.
42    pub name: String,
43    /// Absolute path to the checkout.
44    pub path: PathBuf,
45}
46
47/// Scan every root for a ghq-layout checkout: `<root>/<host>/<owner>/<repo>`
48/// holding a `.git` directory.
49///
50/// A root that does not exist or cannot be read contributes nothing rather
51/// than failing the whole scan - a stale entry left in `[repos] roots` must
52/// not empty the picker for every other root. The same goes for a host or
53/// owner directory partway down: [`subdirs`] turns an unreadable directory
54/// into no children instead of an error.
55///
56/// Results are deduplicated by canonical path and sorted by name, so two
57/// roots that reach the same checkout - a symlink, or one root nested inside
58/// another - list it once.
59pub fn scan(roots: &[PathBuf]) -> Vec<Repo> {
60    let mut seen = HashSet::new();
61    let mut out = Vec::new();
62    for root in roots {
63        for host in subdirs(root) {
64            for owner in subdirs(&host) {
65                for dir in subdirs(&owner) {
66                    if !dir.join(".git").exists() {
67                        continue;
68                    }
69                    let path = dir.canonicalize().unwrap_or_else(|_| dir.clone());
70                    if !seen.insert(path.clone()) {
71                        continue;
72                    }
73                    let name = format!("{}/{}", file_name(&owner), file_name(&dir));
74                    out.push(Repo { name, path });
75                }
76            }
77        }
78    }
79    out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
80    out
81}
82
83/// Resolve a short name (`owner/repo`) to exactly one path.
84///
85/// Refuses rather than choosing when the name is ambiguous - two hosts or two
86/// roots both holding an `owner/repo` - because silently picking one would
87/// send a task to whichever checkout happened to sort first, and that is not
88/// a decision to make quietly on the operator's behalf.
89pub fn resolve(roots: &[PathBuf], name: &str) -> Result<PathBuf> {
90    let hits: Vec<Repo> = scan(roots).into_iter().filter(|r| r.name == name).collect();
91    match hits.len() {
92        1 => Ok(hits.into_iter().next().expect("exactly one hit").path),
93        0 => bail!("no repository named `{name}` found under the configured [repos] roots"),
94        _ => bail!(
95            "`{name}` matches {} repositories:\n{}",
96            hits.len(),
97            hits.iter()
98                .map(|r| format!("  {}", r.path.display()))
99                .collect::<Vec<_>>()
100                .join("\n")
101        ),
102    }
103}
104
105/// Immediate subdirectories of `dir`, or none when it cannot be read.
106fn subdirs(dir: &Path) -> Vec<PathBuf> {
107    std::fs::read_dir(dir)
108        .into_iter()
109        .flatten()
110        .flatten()
111        .map(|entry| entry.path())
112        .filter(|p| p.is_dir())
113        .collect()
114}
115
116fn file_name(path: &Path) -> std::borrow::Cow<'_, str> {
117    path.file_name()
118        .map(|n| n.to_string_lossy())
119        .unwrap_or_default()
120}
121
122/// In-process cache of the last scan.
123///
124/// `Arc<Mutex<..>>` inside rather than deriving over a bare `Mutex`, so
125/// `Cache` itself is cheap to clone - [`crate::web::Ui`] clones its shared
126/// state the same way for its loop and turn-guard bookkeeping.
127#[derive(Debug, Clone, Default)]
128pub struct Cache {
129    state: Arc<Mutex<State>>,
130}
131
132#[derive(Debug, Default)]
133struct State {
134    repos: Vec<Repo>,
135    scanned_at: Option<Instant>,
136}
137
138impl Cache {
139    /// An empty cache. The first [`Cache::list`] always scans.
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// The repositories under `roots`, rescanning when `refresh` is set, the
145    /// cache has never been filled, `ttl` has elapsed, or `ttl` is zero -
146    /// which means "never trust the cache".
147    pub fn list(&self, roots: &[PathBuf], ttl: Duration, refresh: bool) -> Vec<Repo> {
148        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
149        let stale =
150            refresh || ttl.is_zero() || state.scanned_at.is_none_or(|at| at.elapsed() >= ttl);
151        if stale {
152            state.repos = scan(roots);
153            state.scanned_at = Some(Instant::now());
154        }
155        state.repos.clone()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    /// Builds `<root>/<host>/<owner>/<repo>`, with a `.git` directory only
164    /// when `git` is true - the one thing that makes a directory count.
165    fn make(root: &Path, host: &str, owner: &str, repo: &str, git: bool) -> PathBuf {
166        let dir = root.join(host).join(owner).join(repo);
167        std::fs::create_dir_all(&dir).expect("create repo dir");
168        if git {
169            std::fs::create_dir_all(dir.join(".git")).expect("create .git");
170        }
171        dir
172    }
173
174    #[test]
175    fn scan_finds_only_git_checkouts_deduplicated_and_sorted_by_name() {
176        let tmp = tempfile::tempdir().expect("tempdir");
177        let root = tmp.path().to_owned();
178        make(&root, "github.com", "yukimemi", "rvpm", true);
179        make(&root, "github.com", "yukimemi", "magi", true);
180        // No `.git`: a checkout that has not been cloned, or any other
181        // directory that happens to sit at the right depth.
182        make(&root, "github.com", "yukimemi", "not-a-checkout", false);
183
184        let repos = scan(&[root]);
185        let names: Vec<&str> = repos.iter().map(|r| r.name.as_str()).collect();
186        assert_eq!(names, ["yukimemi/magi", "yukimemi/rvpm"]);
187        assert!(repos.iter().all(|r| r.path.is_absolute()));
188    }
189
190    #[test]
191    fn a_missing_root_does_not_empty_the_results_of_the_others() {
192        let tmp = tempfile::tempdir().expect("tempdir");
193        let good = tmp.path().join("good");
194        std::fs::create_dir_all(&good).expect("good root");
195        make(&good, "github.com", "yukimemi", "magi", true);
196        let missing = tmp.path().join("does-not-exist");
197
198        let repos = scan(&[missing, good]);
199        assert_eq!(repos.len(), 1);
200        assert_eq!(repos[0].name, "yukimemi/magi");
201    }
202
203    #[test]
204    fn duplicate_paths_across_roots_are_counted_once() {
205        let tmp = tempfile::tempdir().expect("tempdir");
206        let root = tmp.path().to_owned();
207        make(&root, "github.com", "yukimemi", "magi", true);
208
209        // The same root named twice is the simplest way to exercise the
210        // dedup path without touching symlinks, which are not portable to
211        // set up in a test.
212        let repos = scan(&[root.clone(), root]);
213        assert_eq!(repos.len(), 1);
214    }
215
216    #[test]
217    fn resolve_finds_the_one_match() {
218        let tmp = tempfile::tempdir().expect("tempdir");
219        let root = tmp.path().to_owned();
220        let expected = make(&root, "github.com", "yukimemi", "magi", true);
221
222        let path = resolve(&[root], "yukimemi/magi").expect("must resolve");
223        assert_eq!(path, expected.canonicalize().expect("canonicalize"));
224    }
225
226    #[test]
227    fn resolve_refuses_an_ambiguous_name_and_lists_every_candidate() {
228        let tmp = tempfile::tempdir().expect("tempdir");
229        let root = tmp.path().to_owned();
230        let a = make(&root, "github.com", "yukimemi", "magi", true);
231        let b = make(&root, "gitlab.com", "yukimemi", "magi", true);
232
233        let err = resolve(&[root], "yukimemi/magi")
234            .expect_err("two hosts share the name")
235            .to_string();
236        assert!(err.contains("2 repositories"), "{err}");
237        assert!(
238            err.contains(&a.canonicalize().unwrap().display().to_string()),
239            "{err}"
240        );
241        assert!(
242            err.contains(&b.canonicalize().unwrap().display().to_string()),
243            "{err}"
244        );
245    }
246
247    #[test]
248    fn resolve_names_the_missing_name_when_nothing_matches() {
249        let tmp = tempfile::tempdir().expect("tempdir");
250        let err = resolve(&[tmp.path().to_owned()], "nope/nope")
251            .expect_err("nothing to find")
252            .to_string();
253        assert!(err.contains("nope/nope"), "{err}");
254    }
255
256    #[test]
257    fn the_cache_does_not_rescan_within_the_ttl_but_refresh_forces_it() {
258        let tmp = tempfile::tempdir().expect("tempdir");
259        let root = tmp.path().to_owned();
260        make(&root, "github.com", "yukimemi", "magi", true);
261        let roots = [root.clone()];
262        let cache = Cache::new();
263
264        let first = cache.list(&roots, Duration::from_secs(3600), false);
265        assert_eq!(first.len(), 1);
266
267        // A repository appears after the first scan; within the TTL the
268        // cached answer must not notice it.
269        make(&root, "github.com", "yukimemi", "rvpm", true);
270        let second = cache.list(&roots, Duration::from_secs(3600), false);
271        assert_eq!(second.len(), 1, "a fresh cache must not rescan");
272
273        let refreshed = cache.list(&roots, Duration::from_secs(3600), true);
274        assert_eq!(refreshed.len(), 2, "an explicit refresh must rescan");
275
276        // The TTL now has to be honoured again against the refreshed scan.
277        make(&root, "github.com", "yukimemi", "third", true);
278        let still_cached = cache.list(&roots, Duration::from_secs(3600), false);
279        assert_eq!(still_cached.len(), 2);
280    }
281
282    #[test]
283    fn a_zero_ttl_always_rescans() {
284        let tmp = tempfile::tempdir().expect("tempdir");
285        let root = tmp.path().to_owned();
286        make(&root, "github.com", "yukimemi", "magi", true);
287        let roots = [root.clone()];
288        let cache = Cache::new();
289
290        assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 1);
291        make(&root, "github.com", "yukimemi", "rvpm", true);
292        assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 2);
293    }
294}