anvilsign in

collin/anvil

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