anvilsign in

collin/anvil

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