| 1 | //! Filesystem discovery of repositories under `repos.scan_path`. |
| 2 | |
| 3 | use std::collections::BTreeMap; |
| 4 | use std::path::{Path, PathBuf}; |
| 5 | |
| 6 | /// A repository found on disk. |
| 7 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 8 | pub 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)] |
| 16 | pub 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 | |
| 24 | pub 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 | |
| 51 | fn 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. |
| 115 | pub 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 | |
| 123 | fn is_bare(path: &Path) -> bool { |
| 124 | path.join("HEAD").is_file() && path.join("objects").is_dir() && path.join("refs").is_dir() |
| 125 | } |
| 126 | |
| 127 | fn 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)] |
| 134 | mod 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 | } |