Krolyxon
Commit e758199

ferreniumsrcindexscan.rs

186 lines · 6.0 KB · Rust

1//! Filesystem discovery of repositories under `repos.scan_path`.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6/// A repository found on disk.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct Candidate {
9 /// Path relative to the scan path, `.git` suffix stripped: `forks/foo`.
10 pub name: String,
11 /// The git dir: the bare repo itself, or `<worktree>/.git`.
12 pub git_dir: PathBuf,
13}
14
15#[derive(Debug, Default)]
16pub struct Discovery {
17 pub candidates: Vec<Candidate>,
18 /// Directories walked that are not repositories; watched for repos
19 /// appearing or disappearing.
20 pub dirs: Vec<PathBuf>,
21 pub warnings: Vec<String>,
22}
23
24pub fn discover(scan_path: &Path, max_depth: u32, follow_symlinks: bool) -> Discovery {
25 let mut out = Discovery::default();
26 let mut found: BTreeMap<String, PathBuf> = BTreeMap::new();
27
28 if repo_git_dir(scan_path).is_some() {
29 out.warnings.push(format!(
30 "repos.scan_path {} is itself a repository; point it at the directory containing repositories",
31 scan_path.display()
32 ));
33 return out;
34 }
35 walk(
36 scan_path,
37 scan_path,
38 0,
39 max_depth,
40 follow_symlinks,
41 &mut found,
42 &mut out,
43 );
44 out.candidates = found
45 .into_iter()
46 .map(|(name, git_dir)| Candidate { name, git_dir })
47 .collect();
48 out
49}
50
51fn walk(
52 root: &Path,
53 dir: &Path,
54 depth: u32,
55 max_depth: u32,
56 follow_symlinks: bool,
57 found: &mut BTreeMap<String, PathBuf>,
58 out: &mut Discovery,
59) {
60 let entries = match std::fs::read_dir(dir) {
61 Ok(entries) => entries,
62 Err(e) => {
63 out.warnings
64 .push(format!("cannot read {}: {e}", dir.display()));
65 return;
66 }
67 };
68 out.dirs.push(dir.to_path_buf());
69
70 let mut subdirs: Vec<PathBuf> = entries
71 .flatten()
72 .filter(|entry| !entry.file_name().as_encoded_bytes().starts_with(b"."))
73 .filter(|entry| match entry.file_type() {
74 Ok(t) if t.is_dir() => true,
75 Ok(t) if t.is_symlink() && follow_symlinks => entry.path().is_dir(),
76 _ => false,
77 })
78 .map(|entry| entry.path())
79 .collect();
80 subdirs.sort();
81
82 for path in subdirs {
83 if let Some(git_dir) = repo_git_dir(&path) {
84 let Some(name) = repo_name(root, &path) else {
85 out.warnings.push(format!(
86 "skipping {}: path is not valid UTF-8",
87 path.display()
88 ));
89 continue;
90 };
91 if let Some(existing) = found.get(&name) {
92 out.warnings.push(format!(
93 "name collision: {} and {} are both named {name:?}; keeping the first",
94 existing.display(),
95 git_dir.display()
96 ));
97 continue;
98 }
99 found.insert(name, git_dir);
100 } else if depth + 1 < max_depth {
101 walk(
102 root,
103 &path,
104 depth + 1,
105 max_depth,
106 follow_symlinks,
107 found,
108 out,
109 );
110 }
111 }
112}
113
114/// The git dir if `path` is a bare repository or a worktree with a `.git` dir.
115pub fn repo_git_dir(path: &Path) -> Option<PathBuf> {
116 if is_bare(path) {
117 return Some(path.to_path_buf());
118 }
119 let dot_git = path.join(".git");
120 is_bare(&dot_git).then_some(dot_git)
121}
122
123fn is_bare(path: &Path) -> bool {
124 path.join("HEAD").is_file() && path.join("objects").is_dir() && path.join("refs").is_dir()
125}
126
127fn repo_name(root: &Path, path: &Path) -> Option<String> {
128 let rel = path.strip_prefix(root).ok()?.to_str()?;
129 let name = rel.strip_suffix(".git").unwrap_or(rel);
130 (!name.is_empty() && !name.ends_with('/')).then(|| name.to_string())
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn fake_bare(path: &Path) {
138 std::fs::create_dir_all(path.join("objects")).unwrap();
139 std::fs::create_dir_all(path.join("refs")).unwrap();
140 std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
141 }
142
143 #[test]
144 fn discovers_bare_and_worktree_repos() {
145 let dir = tempfile::tempdir().unwrap();
146 let root = dir.path();
147 fake_bare(&root.join("a.git"));
148 fake_bare(&root.join("forks/b.git"));
149 fake_bare(&root.join("work/c/.git"));
150 fake_bare(&root.join("deep/1/2/3/d.git")); // beyond max_depth 3
151 fake_bare(&root.join(".hidden/e.git"));
152 fake_bare(&root.join("a.git/nested.git")); // inside a repo: not walked
153 std::fs::create_dir_all(root.join("empty")).unwrap();
154
155 let d = discover(root, 3, false);
156 let names: Vec<_> = d.candidates.iter().map(|c| c.name.as_str()).collect();
157 assert_eq!(names, ["a", "forks/b", "work/c"]);
158 assert_eq!(d.candidates[2].git_dir, root.join("work/c/.git"));
159 assert!(d.dirs.contains(&root.join("forks")));
160 assert!(d.dirs.contains(&root.join("empty")));
161 assert!(!d.dirs.contains(&root.join("a.git")));
162 assert!(d.warnings.is_empty(), "{:?}", d.warnings);
163 }
164
165 #[test]
166 fn collisions_warn_and_keep_first() {
167 let dir = tempfile::tempdir().unwrap();
168 fake_bare(&dir.path().join("x"));
169 fake_bare(&dir.path().join("x.git"));
170 let d = discover(dir.path(), 3, false);
171 assert_eq!(d.candidates.len(), 1);
172 assert_eq!(d.candidates[0].git_dir, dir.path().join("x"));
173 assert!(d.warnings[0].contains("name collision"));
174 }
175
176 #[test]
177 fn symlinks_followed_only_when_enabled() {
178 let dir = tempfile::tempdir().unwrap();
179 let elsewhere = tempfile::tempdir().unwrap();
180 fake_bare(&elsewhere.path().join("r.git"));
181 std::os::unix::fs::symlink(elsewhere.path().join("r.git"), dir.path().join("r.git"))
182 .unwrap();
183 assert!(discover(dir.path(), 3, false).candidates.is_empty());
184 assert_eq!(discover(dir.path(), 3, true).candidates.len(), 1);
185 }
186}