| 1 | //! Repository discovery and the in-memory repository index. |
| 2 | //! |
| 3 | //! Readers get a consistent snapshot via [`Indexer::load`] without locking. |
| 4 | //! Writers (full scans and single-repo refreshes) are serialized and swap in |
| 5 | //! a new snapshot copy-on-write. |
| 6 | |
| 7 | pub mod languages; |
| 8 | pub mod languages_table; |
| 9 | pub mod meta; |
| 10 | pub mod scan; |
| 11 | pub mod watch; |
| 12 | |
| 13 | use std::collections::{BTreeMap, BTreeSet}; |
| 14 | use std::path::{Path, PathBuf}; |
| 15 | use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; |
| 16 | use std::sync::{Arc, Mutex}; |
| 17 | use std::time::{Duration, Instant}; |
| 18 | |
| 19 | use arc_swap::ArcSwap; |
| 20 | |
| 21 | use crate::config::Config; |
| 22 | use crate::git::ThreadSafeRepository; |
| 23 | use meta::{Inspected, RepoMeta}; |
| 24 | use scan::Candidate; |
| 25 | |
| 26 | pub struct Repo { |
| 27 | pub name: String, |
| 28 | pub git_dir: PathBuf, |
| 29 | pub meta: RepoMeta, |
| 30 | pub gix: ThreadSafeRepository, |
| 31 | /// Changes whenever this repo's refs or metadata change; part of cache |
| 32 | /// keys for ref-dependent data. |
| 33 | pub generation: u64, |
| 34 | } |
| 35 | |
| 36 | #[derive(Default, Clone)] |
| 37 | pub struct RepoIndex { |
| 38 | /// Visible repositories by name. |
| 39 | pub repos: BTreeMap<String, Arc<Repo>>, |
| 40 | /// Every discovered git dir (hidden ones included) → name. |
| 41 | pub git_dirs: BTreeMap<PathBuf, String>, |
| 42 | pub hidden: BTreeSet<String>, |
| 43 | /// Non-repository directories walked during discovery. |
| 44 | pub dirs: Vec<PathBuf>, |
| 45 | pub warnings: Vec<String>, |
| 46 | } |
| 47 | |
| 48 | #[derive(Debug, Default, PartialEq, Eq)] |
| 49 | pub struct ScanStats { |
| 50 | pub total: usize, |
| 51 | pub hidden: usize, |
| 52 | pub added: usize, |
| 53 | pub removed: usize, |
| 54 | pub changed: usize, |
| 55 | pub elapsed: Duration, |
| 56 | } |
| 57 | |
| 58 | impl ScanStats { |
| 59 | pub fn any_change(&self) -> bool { |
| 60 | self.added + self.removed + self.changed > 0 |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | #[derive(Debug, PartialEq, Eq)] |
| 65 | pub enum Refresh { |
| 66 | Unchanged, |
| 67 | Updated(String), |
| 68 | Hidden(String), |
| 69 | /// The path is not (or no longer) a known repository: do a full scan. |
| 70 | NeedsRescan, |
| 71 | Failed(String), |
| 72 | } |
| 73 | |
| 74 | pub struct Indexer { |
| 75 | current: ArcSwap<RepoIndex>, |
| 76 | generation: AtomicU64, |
| 77 | write: Mutex<()>, |
| 78 | } |
| 79 | |
| 80 | impl Default for Indexer { |
| 81 | fn default() -> Self { |
| 82 | Self::new() |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | impl Indexer { |
| 87 | pub fn new() -> Self { |
| 88 | Indexer { |
| 89 | current: ArcSwap::from_pointee(RepoIndex::default()), |
| 90 | generation: AtomicU64::new(0), |
| 91 | write: Mutex::new(()), |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | pub fn load(&self) -> Arc<RepoIndex> { |
| 96 | self.current.load_full() |
| 97 | } |
| 98 | |
| 99 | /// Rediscover everything under the scan path and rebuild the index. |
| 100 | pub fn full_scan(&self, config: &Config) -> ScanStats { |
| 101 | let _guard = self.write.lock().unwrap_or_else(|e| e.into_inner()); |
| 102 | let start = Instant::now(); |
| 103 | let old = self.current.load_full(); |
| 104 | let discovery = scan::discover( |
| 105 | &config.repos.scan_path, |
| 106 | config.repos.max_depth, |
| 107 | config.repos.follow_symlinks, |
| 108 | ); |
| 109 | |
| 110 | let mut new = RepoIndex { |
| 111 | dirs: discovery.dirs, |
| 112 | warnings: discovery.warnings, |
| 113 | ..RepoIndex::default() |
| 114 | }; |
| 115 | let inspected = inspect_all(config, &discovery.candidates); |
| 116 | for (candidate, result) in discovery.candidates.into_iter().zip(inspected) { |
| 117 | new.git_dirs |
| 118 | .insert(candidate.git_dir.clone(), candidate.name.clone()); |
| 119 | match result { |
| 120 | Err(e) => new.warnings.push(format!("{}: {e:#}", candidate.name)), |
| 121 | Ok(inspected) => { |
| 122 | new.warnings.extend(inspected.warnings.iter().cloned()); |
| 123 | if inspected.hidden { |
| 124 | new.hidden.insert(candidate.name); |
| 125 | } else { |
| 126 | let repo = self.make_repo(&old, candidate, inspected); |
| 127 | new.repos.insert(repo.name.clone(), repo); |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | let known: BTreeSet<&String> = new.git_dirs.values().collect(); |
| 133 | for o in &config.repo_overrides { |
| 134 | if !known.contains(&o.name) { |
| 135 | new.warnings.push(format!( |
| 136 | "[[repo]] {:?} does not match any repository", |
| 137 | o.name |
| 138 | )); |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | let mut stats = ScanStats { |
| 143 | total: new.repos.len(), |
| 144 | hidden: new.hidden.len(), |
| 145 | ..ScanStats::default() |
| 146 | }; |
| 147 | for (name, repo) in &new.repos { |
| 148 | match old.repos.get(name) { |
| 149 | None => stats.added += 1, |
| 150 | Some(prev) if !Arc::ptr_eq(prev, repo) => stats.changed += 1, |
| 151 | Some(_) => {} |
| 152 | } |
| 153 | } |
| 154 | stats.removed = old |
| 155 | .repos |
| 156 | .keys() |
| 157 | .filter(|name| !new.repos.contains_key(*name)) |
| 158 | .count(); |
| 159 | self.current.store(Arc::new(new)); |
| 160 | stats.elapsed = start.elapsed(); |
| 161 | stats |
| 162 | } |
| 163 | |
| 164 | /// Re-inspect the single repository at `git_dir` (after a ref change). |
| 165 | pub fn refresh(&self, config: &Config, git_dir: &Path) -> Refresh { |
| 166 | let _guard = self.write.lock().unwrap_or_else(|e| e.into_inner()); |
| 167 | let old = self.current.load_full(); |
| 168 | let Some(name) = old.git_dirs.get(git_dir).cloned() else { |
| 169 | return Refresh::NeedsRescan; |
| 170 | }; |
| 171 | if scan::repo_git_dir(git_dir).as_deref() != Some(git_dir) { |
| 172 | return Refresh::NeedsRescan; |
| 173 | } |
| 174 | let candidate = Candidate { |
| 175 | name: name.clone(), |
| 176 | git_dir: git_dir.to_path_buf(), |
| 177 | }; |
| 178 | let inspected = match meta::inspect(config, &candidate) { |
| 179 | Ok(inspected) => inspected, |
| 180 | // Keep serving the previous state; the next event or poll retries. |
| 181 | Err(e) => return Refresh::Failed(format!("{name}: {e:#}")), |
| 182 | }; |
| 183 | |
| 184 | let mut new = (*old).clone(); |
| 185 | if inspected.hidden { |
| 186 | if new.repos.remove(&name).is_none() { |
| 187 | return Refresh::Unchanged; |
| 188 | } |
| 189 | new.hidden.insert(name.clone()); |
| 190 | self.current.store(Arc::new(new)); |
| 191 | return Refresh::Hidden(name); |
| 192 | } |
| 193 | let repo = self.make_repo(&old, candidate, inspected); |
| 194 | if old |
| 195 | .repos |
| 196 | .get(&name) |
| 197 | .is_some_and(|prev| Arc::ptr_eq(prev, &repo)) |
| 198 | { |
| 199 | return Refresh::Unchanged; |
| 200 | } |
| 201 | new.hidden.remove(&name); |
| 202 | new.repos.insert(name.clone(), repo); |
| 203 | self.current.store(Arc::new(new)); |
| 204 | Refresh::Updated(name) |
| 205 | } |
| 206 | |
| 207 | /// Reuse the previous entry when nothing changed, so its generation (and |
| 208 | /// everything cached under it) stays valid. |
| 209 | fn make_repo(&self, old: &RepoIndex, candidate: Candidate, inspected: Inspected) -> Arc<Repo> { |
| 210 | if let Some(prev) = old.repos.get(&candidate.name) |
| 211 | && prev.git_dir == candidate.git_dir |
| 212 | && prev.meta == inspected.meta |
| 213 | { |
| 214 | return prev.clone(); |
| 215 | } |
| 216 | Arc::new(Repo { |
| 217 | name: candidate.name, |
| 218 | git_dir: candidate.git_dir, |
| 219 | meta: inspected.meta, |
| 220 | gix: inspected.gix, |
| 221 | generation: self.generation.fetch_add(1, Ordering::Relaxed) + 1, |
| 222 | }) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /// Inspect candidates on a few threads; results keep the input order. |
| 227 | fn inspect_all(config: &Config, candidates: &[Candidate]) -> Vec<anyhow::Result<Inspected>> { |
| 228 | let workers = std::thread::available_parallelism() |
| 229 | .map_or(4, |n| n.get()) |
| 230 | .min(candidates.len()) |
| 231 | .max(1); |
| 232 | let next = AtomicUsize::new(0); |
| 233 | let mut results: Vec<(usize, anyhow::Result<Inspected>)> = std::thread::scope(|s| { |
| 234 | let handles: Vec<_> = (0..workers) |
| 235 | .map(|_| { |
| 236 | s.spawn(|| { |
| 237 | let mut out = Vec::new(); |
| 238 | loop { |
| 239 | let i = next.fetch_add(1, Ordering::Relaxed); |
| 240 | let Some(candidate) = candidates.get(i) else { |
| 241 | break out; |
| 242 | }; |
| 243 | out.push((i, meta::inspect(config, candidate))); |
| 244 | } |
| 245 | }) |
| 246 | }) |
| 247 | .collect(); |
| 248 | handles |
| 249 | .into_iter() |
| 250 | .flat_map(|h| h.join().expect("inspect worker panicked")) |
| 251 | .collect() |
| 252 | }); |
| 253 | results.sort_by_key(|(i, _)| *i); |
| 254 | results.into_iter().map(|(_, r)| r).collect() |
| 255 | } |