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.
15//!
16//! # One implementation, two callers
17//!
18//! [`scan`] is the whole surface, and both `magi repos` and `GET /api/repos`
19//! (see [`crate::web`]) call it rather than each walking the filesystem in
20//! its own way. [`Cache`] wraps [`scan`] for the web server, which asks on
21//! every request; the CLI, invoked once per command, has no cache to keep.
22
23use std::collections::HashSet;
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, Mutex, PoisonError};
26use std::time::{Duration, Instant};
27
28use serde::Serialize;
29
30/// One repository found under a configured root.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct Repo {
33    /// `<owner>/<repo>`, the short name an operator types or picks from a
34    /// list.
35    pub name: String,
36    /// Absolute path to the checkout.
37    pub path: PathBuf,
38}
39
40/// Scan every root for a ghq-layout checkout: `<root>/<host>/<owner>/<repo>`
41/// holding a `.git` directory.
42///
43/// A root that does not exist or cannot be read contributes nothing rather
44/// than failing the whole scan - a stale entry left in `[repos] roots` must
45/// not empty the picker for every other root. The same goes for a host or
46/// owner directory partway down: [`subdirs`] turns an unreadable directory
47/// into no children instead of an error.
48///
49/// Results are deduplicated by canonical path and sorted by name, so two
50/// roots that reach the same checkout - a symlink, or one root nested inside
51/// another - list it once.
52pub fn scan(roots: &[PathBuf]) -> Vec<Repo> {
53    let mut seen = HashSet::new();
54    let mut out = Vec::new();
55    for root in roots {
56        for host in subdirs(root) {
57            for owner in subdirs(&host) {
58                for dir in subdirs(&owner) {
59                    if !dir.join(".git").exists() {
60                        continue;
61                    }
62                    let path = dir.canonicalize().unwrap_or_else(|_| dir.clone());
63                    if !seen.insert(path.clone()) {
64                        continue;
65                    }
66                    let name = format!("{}/{}", file_name(&owner), file_name(&dir));
67                    out.push(Repo { name, path });
68                }
69            }
70        }
71    }
72    out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
73    out
74}
75
76/// Immediate subdirectories of `dir`, or none when it cannot be read.
77fn subdirs(dir: &Path) -> Vec<PathBuf> {
78    std::fs::read_dir(dir)
79        .into_iter()
80        .flatten()
81        .flatten()
82        .map(|entry| entry.path())
83        .filter(|p| p.is_dir())
84        .collect()
85}
86
87fn file_name(path: &Path) -> std::borrow::Cow<'_, str> {
88    path.file_name()
89        .map(|n| n.to_string_lossy())
90        .unwrap_or_default()
91}
92
93/// In-process cache of the last scan.
94///
95/// `Arc<Mutex<..>>` inside rather than deriving over a bare `Mutex`, so
96/// `Cache` itself is cheap to clone - [`crate::web::Ui`] clones its shared
97/// state the same way for its loop and turn-guard bookkeeping.
98#[derive(Debug, Clone, Default)]
99pub struct Cache {
100    state: Arc<Mutex<State>>,
101}
102
103#[derive(Debug, Default)]
104struct State {
105    repos: Vec<Repo>,
106    scanned_at: Option<Instant>,
107}
108
109impl Cache {
110    /// An empty cache. The first [`Cache::list`] always scans.
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// The repositories under `roots`, rescanning when `refresh` is set, the
116    /// cache has never been filled, `ttl` has elapsed, or `ttl` is zero -
117    /// which means "never trust the cache".
118    pub fn list(&self, roots: &[PathBuf], ttl: Duration, refresh: bool) -> Vec<Repo> {
119        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
120        let stale =
121            refresh || ttl.is_zero() || state.scanned_at.is_none_or(|at| at.elapsed() >= ttl);
122        if stale {
123            state.repos = scan(roots);
124            state.scanned_at = Some(Instant::now());
125        }
126        state.repos.clone()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    /// Builds `<root>/<host>/<owner>/<repo>`, with a `.git` directory only
135    /// when `git` is true - the one thing that makes a directory count.
136    fn make(root: &Path, host: &str, owner: &str, repo: &str, git: bool) -> PathBuf {
137        let dir = root.join(host).join(owner).join(repo);
138        std::fs::create_dir_all(&dir).expect("create repo dir");
139        if git {
140            std::fs::create_dir_all(dir.join(".git")).expect("create .git");
141        }
142        dir
143    }
144
145    #[test]
146    fn scan_finds_only_git_checkouts_deduplicated_and_sorted_by_name() {
147        let tmp = tempfile::tempdir().expect("tempdir");
148        let root = tmp.path().to_owned();
149        make(&root, "github.com", "yukimemi", "rvpm", true);
150        make(&root, "github.com", "yukimemi", "magi", true);
151        // No `.git`: a checkout that has not been cloned, or any other
152        // directory that happens to sit at the right depth.
153        make(&root, "github.com", "yukimemi", "not-a-checkout", false);
154
155        let repos = scan(&[root]);
156        let names: Vec<&str> = repos.iter().map(|r| r.name.as_str()).collect();
157        assert_eq!(names, ["yukimemi/magi", "yukimemi/rvpm"]);
158        assert!(repos.iter().all(|r| r.path.is_absolute()));
159    }
160
161    #[test]
162    fn a_missing_root_does_not_empty_the_results_of_the_others() {
163        let tmp = tempfile::tempdir().expect("tempdir");
164        let good = tmp.path().join("good");
165        std::fs::create_dir_all(&good).expect("good root");
166        make(&good, "github.com", "yukimemi", "magi", true);
167        let missing = tmp.path().join("does-not-exist");
168
169        let repos = scan(&[missing, good]);
170        assert_eq!(repos.len(), 1);
171        assert_eq!(repos[0].name, "yukimemi/magi");
172    }
173
174    #[test]
175    fn duplicate_paths_across_roots_are_counted_once() {
176        let tmp = tempfile::tempdir().expect("tempdir");
177        let root = tmp.path().to_owned();
178        make(&root, "github.com", "yukimemi", "magi", true);
179
180        // The same root named twice is the simplest way to exercise the
181        // dedup path without touching symlinks, which are not portable to
182        // set up in a test.
183        let repos = scan(&[root.clone(), root]);
184        assert_eq!(repos.len(), 1);
185    }
186
187    #[test]
188    fn the_cache_does_not_rescan_within_the_ttl_but_refresh_forces_it() {
189        let tmp = tempfile::tempdir().expect("tempdir");
190        let root = tmp.path().to_owned();
191        make(&root, "github.com", "yukimemi", "magi", true);
192        let roots = [root.clone()];
193        let cache = Cache::new();
194
195        let first = cache.list(&roots, Duration::from_secs(3600), false);
196        assert_eq!(first.len(), 1);
197
198        // A repository appears after the first scan; within the TTL the
199        // cached answer must not notice it.
200        make(&root, "github.com", "yukimemi", "rvpm", true);
201        let second = cache.list(&roots, Duration::from_secs(3600), false);
202        assert_eq!(second.len(), 1, "a fresh cache must not rescan");
203
204        let refreshed = cache.list(&roots, Duration::from_secs(3600), true);
205        assert_eq!(refreshed.len(), 2, "an explicit refresh must rescan");
206
207        // The TTL now has to be honoured again against the refreshed scan.
208        make(&root, "github.com", "yukimemi", "third", true);
209        let still_cached = cache.list(&roots, Duration::from_secs(3600), false);
210        assert_eq!(still_cached.len(), 2);
211    }
212
213    #[test]
214    fn a_zero_ttl_always_rescans() {
215        let tmp = tempfile::tempdir().expect("tempdir");
216        let root = tmp.path().to_owned();
217        make(&root, "github.com", "yukimemi", "magi", true);
218        let roots = [root.clone()];
219        let cache = Cache::new();
220
221        assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 1);
222        make(&root, "github.com", "yukimemi", "rvpm", true);
223        assert_eq!(cache.list(&roots, Duration::from_secs(0), false).len(), 2);
224    }
225}