Krolyxon
Branch master

ferreniumsrcindexmod.rs

255 lines · 8.1 KB · Rust

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
7pub mod languages;
8pub mod languages_table;
9pub mod meta;
10pub mod scan;
11pub mod watch;
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
16use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18
19use arc_swap::ArcSwap;
20
21use crate::config::Config;
22use crate::git::ThreadSafeRepository;
23use meta::{Inspected, RepoMeta};
24use scan::Candidate;
25
26pub 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)]
37pub 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)]
49pub 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
58impl ScanStats {
59 pub fn any_change(&self) -> bool {
60 self.added + self.removed + self.changed > 0
61 }
62}
63
64#[derive(Debug, PartialEq, Eq)]
65pub 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
74pub struct Indexer {
75 current: ArcSwap<RepoIndex>,
76 generation: AtomicU64,
77 write: Mutex<()>,
78}
79
80impl Default for Indexer {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl 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.
227fn 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}