gitserver_core/
discovery.rs1use 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#[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#[derive(Debug, Clone)]
25pub struct RepoStore {
26 root: PathBuf,
27 max_depth: u32,
28 repos: Vec<RepoInfo>,
29}
30
31impl RepoStore {
32 pub fn discover(root: PathBuf, max_depth: u32) -> Result<Self> {
37 let root = root.canonicalize()?;
38 let mut repos = Vec::new();
39
40 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 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 pub fn list(&self) -> &[RepoInfo] {
67 &self.repos
68 }
69
70 pub fn root(&self) -> &Path {
72 &self.root
73 }
74
75 pub fn max_depth(&self) -> u32 {
77 self.max_depth
78 }
79
80 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
88fn 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 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 }
141 _ => {
142 if depth < max_depth {
144 walk_dir(root, &path, depth + 1, max_depth, repos)?;
145 }
146 }
147 }
148 }
149
150 Ok(())
151}
152
153fn 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 let store_shallow = RepoStore::discover(dir.path().to_path_buf(), 2).unwrap();
215 assert_eq!(store_shallow.list().len(), 0);
216
217 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 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}