Skip to main content

gitserver_core/
discovery.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6pub use crate::dynamic_registry::{DynamicRepoRegistry, MutableRepoRegistry, RepoResolver};
7use crate::error::{Error, Result};
8
9const DEFAULT_GIT_DESCRIPTION: &str =
10    "Unnamed repository; edit this file 'description' to name the repository.";
11
12/// Information about a discovered bare git repository.
13#[derive(Debug, Clone, Serialize)]
14pub struct RepoInfo {
15    pub name: String,
16    pub relative_path: String,
17    #[serde(skip)]
18    pub absolute_path: PathBuf,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub description: Option<String>,
21}
22
23/// A store of discovered repositories under a root directory.
24#[derive(Debug, Clone)]
25pub struct RepoStore {
26    root: PathBuf,
27    max_depth: u32,
28    repos: Vec<RepoInfo>,
29}
30
31impl RepoStore {
32    /// Scan `root` recursively up to `max_depth` levels for bare git repositories.
33    ///
34    /// `max_depth = 0` means only repositories directly inside `root`.
35    /// `max_depth = 3` means up to 3 levels of subdirectories below `root`.
36    pub fn discover(root: PathBuf, max_depth: u32) -> Result<Self> {
37        let root = root.canonicalize()?;
38        let mut repos = Vec::new();
39
40        // Walk starts at depth 0 (root itself). We scan children of root at depth 1,
41        // and allow descending up to max_depth subdirectory levels below root.
42        walk_dir(&root, &root, 0, max_depth, &mut repos)?;
43
44        repos.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
45
46        Ok(Self {
47            root,
48            max_depth,
49            repos,
50        })
51    }
52
53    /// Resolve a relative path to a `RepoInfo`.
54    ///
55    /// Uses `crate::path::resolve_repo_path` for validation, then matches by
56    /// canonical absolute path.
57    pub fn resolve(&self, relative: &str) -> Result<&RepoInfo> {
58        let canonical = crate::path::resolve_repo_path(&self.root, relative)?;
59        self.repos
60            .iter()
61            .find(|r| r.absolute_path == canonical)
62            .ok_or_else(|| Error::RepoNotFound(relative.to_string()))
63    }
64
65    /// Returns all discovered repositories, sorted by relative path.
66    pub fn list(&self) -> &[RepoInfo] {
67        &self.repos
68    }
69
70    /// Returns the root directory used for discovery.
71    pub fn root(&self) -> &Path {
72        &self.root
73    }
74
75    /// Returns the configured maximum discovery depth.
76    pub fn max_depth(&self) -> u32 {
77        self.max_depth
78    }
79
80    /// Re-run repository discovery using the original root and max depth.
81    pub fn refresh(&mut self) -> Result<()> {
82        let refreshed = Self::discover(self.root.clone(), self.max_depth)?;
83        self.repos = refreshed.repos;
84        Ok(())
85    }
86}
87
88/// Recursively walk `dir`, recording bare repositories.
89///
90/// `depth` is the current depth relative to `root` (root itself is depth 0).
91/// Entries *inside* root are at depth 1. We descend while `depth <= max_depth`.
92fn walk_dir(
93    root: &Path,
94    dir: &Path,
95    depth: u32,
96    max_depth: u32,
97    repos: &mut Vec<RepoInfo>,
98) -> Result<()> {
99    let read = match fs::read_dir(dir) {
100        Ok(r) => r,
101        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()),
102        Err(e) => return Err(Error::Io(e)),
103    };
104
105    for entry in read {
106        let entry = entry?;
107        let path = entry.path();
108
109        let metadata = match fs::metadata(&path) {
110            Ok(m) => m,
111            Err(_) => continue,
112        };
113
114        if !metadata.is_dir() {
115            continue;
116        }
117
118        // Try to open as a git repository.
119        match gix::open(&path) {
120            Ok(repo) if repo.is_bare() => {
121                let absolute_path = path.canonicalize()?;
122                let relative_path = absolute_path
123                    .strip_prefix(root)
124                    .expect("discovered path must be inside root")
125                    .to_string_lossy()
126                    .into_owned();
127                let name = absolute_path
128                    .file_name()
129                    .map(|n| n.to_string_lossy().into_owned())
130                    .unwrap_or_else(|| relative_path.clone());
131                let description = read_description(&absolute_path);
132
133                repos.push(RepoInfo {
134                    name,
135                    relative_path,
136                    absolute_path,
137                    description,
138                });
139                // Do not descend into a repository directory.
140            }
141            _ => {
142                // Not a bare repo (or open failed). Descend if within max_depth.
143                if depth < max_depth {
144                    walk_dir(root, &path, depth + 1, max_depth, repos)?;
145                }
146            }
147        }
148    }
149
150    Ok(())
151}
152
153/// Read the `description` file from a bare repository directory.
154///
155/// Returns `None` if the file is absent, unreadable, or contains the default
156/// git placeholder text.
157fn read_description(repo_path: &Path) -> Option<String> {
158    let desc_path = repo_path.join("description");
159    let content = fs::read_to_string(&desc_path).ok()?;
160    let trimmed = content.trim().to_string();
161    if trimmed.is_empty() || trimmed == DEFAULT_GIT_DESCRIPTION {
162        None
163    } else {
164        Some(trimmed)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use std::path::Path;
171    use std::process::Command;
172
173    use tempfile::TempDir;
174
175    use super::*;
176
177    fn create_bare_repo(path: &Path) {
178        Command::new("git")
179            .args(["init", "--bare", path.to_str().unwrap()])
180            .output()
181            .expect("git init --bare failed");
182    }
183
184    #[test]
185    fn discover_finds_bare_repos() {
186        let dir = TempDir::new().unwrap();
187        create_bare_repo(&dir.path().join("alpha.git"));
188        create_bare_repo(&dir.path().join("beta.git"));
189
190        let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
191        assert_eq!(store.list().len(), 2);
192    }
193
194    #[test]
195    fn discover_finds_nested_repos() {
196        let dir = TempDir::new().unwrap();
197        let repo_path = dir.path().join("org").join("project.git");
198        std::fs::create_dir_all(&repo_path).unwrap();
199        create_bare_repo(&repo_path);
200
201        let store = RepoStore::discover(dir.path().to_path_buf(), 1).unwrap();
202        assert_eq!(store.list().len(), 1);
203        assert_eq!(store.list()[0].relative_path, "org/project.git");
204    }
205
206    #[test]
207    fn discover_respects_max_depth() {
208        let dir = TempDir::new().unwrap();
209        let deep = dir.path().join("a").join("b").join("c").join("deep.git");
210        std::fs::create_dir_all(&deep).unwrap();
211        create_bare_repo(&deep);
212
213        // max_depth 2 should not find it (it is 3 levels below root)
214        let store_shallow = RepoStore::discover(dir.path().to_path_buf(), 2).unwrap();
215        assert_eq!(store_shallow.list().len(), 0);
216
217        // max_depth 3 should find it
218        let store_deep = RepoStore::discover(dir.path().to_path_buf(), 3).unwrap();
219        assert_eq!(store_deep.list().len(), 1);
220    }
221
222    #[test]
223    fn discover_max_depth_zero_only_root_level() {
224        let dir = TempDir::new().unwrap();
225        create_bare_repo(&dir.path().join("root-level.git"));
226        let nested = dir.path().join("nested").join("deep.git");
227        std::fs::create_dir_all(&nested).unwrap();
228        create_bare_repo(&nested);
229
230        let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
231        assert_eq!(store.list().len(), 1);
232        assert_eq!(store.list()[0].relative_path, "root-level.git");
233    }
234
235    #[test]
236    fn discover_ignores_non_bare_dirs() {
237        let dir = TempDir::new().unwrap();
238        // A plain directory -- not a git repo
239        std::fs::create_dir(dir.path().join("just-a-dir")).unwrap();
240
241        let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
242        assert_eq!(store.list().len(), 0);
243    }
244
245    #[test]
246    fn resolve_existing_repo() {
247        let dir = TempDir::new().unwrap();
248        create_bare_repo(&dir.path().join("myrepo.git"));
249
250        let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
251        let info = store.resolve("myrepo.git").unwrap();
252        assert_eq!(info.relative_path, "myrepo.git");
253        assert_eq!(info.name, "myrepo.git");
254    }
255
256    #[test]
257    fn resolve_missing_repo() {
258        let dir = TempDir::new().unwrap();
259        create_bare_repo(&dir.path().join("exists.git"));
260
261        let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
262        let err = store.resolve("nope.git").unwrap_err();
263        assert!(matches!(err, Error::RepoNotFound(_)));
264    }
265
266    #[test]
267    fn reads_description_file() {
268        let dir = TempDir::new().unwrap();
269        let repo_path = dir.path().join("described.git");
270        create_bare_repo(&repo_path);
271        std::fs::write(repo_path.join("description"), "A test repository\n").unwrap();
272
273        let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
274        assert_eq!(store.list().len(), 1);
275        assert_eq!(
276            store.list()[0].description.as_deref(),
277            Some("A test repository")
278        );
279    }
280
281    #[test]
282    fn refresh_picks_up_new_repositories() {
283        let dir = TempDir::new().unwrap();
284        create_bare_repo(&dir.path().join("alpha.git"));
285
286        let mut store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap();
287        assert_eq!(store.list().len(), 1);
288
289        create_bare_repo(&dir.path().join("beta.git"));
290        store.refresh().unwrap();
291
292        assert_eq!(store.list().len(), 2);
293        assert!(store.list().iter().any(|repo| repo.name == "beta.git"));
294    }
295}