Skip to main content

git_cache_proxy/
evict.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Bounding the on-disk cache with LRU eviction of idle mirrors.
3//!
4//! When a byte cap is configured (`--cache-max-mb`), a [`CacheIndex`] tracks every
5//! mirror's size and access order in memory. The request path only ever does O(1)
6//! bookkeeping against it - never disk IO:
7//!   - `touch` on every request (a served cache hit counts as use),
8//!   - `mark_changed` after a clone/fetch, flagging the mirror for (re)measurement.
9//!
10//! All disk work - measuring a changed mirror's size, and evicting mirrors - runs
11//! in the background [`run`] task, off the critical path, so a client's clone/fetch
12//! is never blocked by cache maintenance. Access order lives in an `lru::LruCache`
13//! (a hashmap plus an intrusive list), so `touch` promotes in O(1) and eviction
14//! pops the least-recently-used tail until back under the cap - no re-sorting. An
15//! evicted mirror is transparently re-cloned on its next request, so eviction is a
16//! cache-management concern only, never a correctness one. With no cap set, no index
17//! is built and the default path keeps its current zero-overhead unbounded-growth
18//! behaviour.
19
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, Mutex, MutexGuard};
23use std::time::SystemTime;
24
25use lru::LruCache;
26use tokio::sync::{Notify, watch};
27
28use crate::git::GitCache;
29use crate::metrics::Metrics;
30
31struct Inner {
32    /// Mirror name -> size, kept in access order (front = most-recently-used, back =
33    /// least). The `lru` crate does the O(1) promote-on-use and tail eviction.
34    cache: LruCache<String, u64>,
35    /// Mirrors whose size changed and needs (re)measuring by the background task.
36    dirty: HashSet<String>,
37    /// Sum of the sizes in `cache`, maintained incrementally so the cap check is
38    /// O(1). The byte total, not `LruCache`'s item count, is what bounds the cache.
39    total: u64,
40}
41
42/// In-memory record of the on-disk mirror cache, plus the eviction trigger. Shared
43/// (via `Arc`) between the `GitCache` that mutates it on clone/fetch/serve and the
44/// background task in [`run`] that measures and evicts.
45pub struct CacheIndex {
46    cache_root: PathBuf,
47    max_bytes: u64,
48    metrics: Arc<Metrics>,
49    /// Woken when a mirror changes or the cache may be over cap. `notify_one`
50    /// coalesces a burst into a single maintenance pass and stores a permit if the
51    /// task is mid-pass, so no wakeup is lost.
52    work: Notify,
53    state: Mutex<Inner>,
54}
55
56impl CacheIndex {
57    /// Build the index by scanning `cache_root` once. Seeds access order from each
58    /// mirror's newest file mtime (oldest first, so the oldest lands at the LRU
59    /// tail), so recency roughly survives a restart. Blocking, but runs at startup
60    /// before the server binds.
61    pub fn new(cache_root: PathBuf, max_bytes: u64, metrics: Arc<Metrics>) -> Arc<Self> {
62        // Both bare mirrors and cached LFS objects share one byte budget and LRU.
63        let mut entries = find_mirrors(&cache_root);
64        entries.extend(find_lfs_blobs(&cache_root));
65        entries.sort_by_key(|m| m.mtime); // oldest first -> pushed to the LRU tail first
66        // Unbounded: the cap is enforced by byte total, not `LruCache`'s item count.
67        let mut cache: LruCache<String, u64> = LruCache::unbounded();
68        let mut total = 0u64;
69        for m in entries {
70            total += m.size;
71            cache.put(m.name, m.size);
72        }
73        let inner = Inner {
74            cache,
75            dirty: HashSet::new(),
76            total,
77        };
78        metrics.set_cache_size(total, inner.cache.len());
79        let over = total > max_bytes;
80        let idx = Arc::new(Self {
81            cache_root,
82            max_bytes,
83            metrics,
84            work: Notify::new(),
85            state: Mutex::new(inner),
86        });
87        if over {
88            idx.work.notify_one(); // a previous run may have left the cache over-cap
89        }
90        idx
91    }
92
93    /// Mark a repo used. Serving does not grow the cache, so this never wakes the
94    /// evictor; it only keeps the access order honest. No-op for a repo not yet
95    /// tracked (its first clone tracks it via `mark_changed`).
96    pub fn touch(&self, name: &str) {
97        // `get` promotes to most-recently-used; the value itself is unused.
98        let _ = self.lock().cache.get(name);
99    }
100
101    /// Record a cached LFS object with its exact size and wake the background task in
102    /// case the cache is now over cap. Unlike a mirror, an object is immutable and its
103    /// size is known at store time, so it needs no deferred measurement - it goes
104    /// straight into the index at its final size and is promoted to most-recently-used.
105    pub fn record_blob(&self, key: &str, size: u64) {
106        {
107            let mut inner = self.lock();
108            let old = inner.cache.put(key.to_string(), size); // inserts and promotes to MRU
109            inner.total = inner.total - old.unwrap_or(0) + size;
110            self.set_gauges(&inner);
111        }
112        self.work.notify_one();
113    }
114
115    /// Flag a mirror as changed after a clone/fetch: promote it (it was just used),
116    /// schedule it for measurement, and wake the background task. O(1) bookkeeping
117    /// only - the size walk happens off the request path.
118    pub fn mark_changed(&self, name: &str) {
119        {
120            let mut inner = self.lock();
121            // `get` promotes an existing entry; otherwise track it with a placeholder
122            // size until the background pass measures it.
123            if inner.cache.get(name).is_none() {
124                inner.cache.put(name.to_string(), 0);
125            }
126            inner.dirty.insert(name.to_string());
127            self.set_gauges(&inner);
128        }
129        self.work.notify_one();
130    }
131
132    /// On-disk path of a tracked mirror.
133    pub fn cache_dir(&self, name: &str) -> PathBuf {
134        self.cache_root.join(name)
135    }
136
137    /// Current `(total_bytes, mirror_count)` - the values mirrored to the gauges.
138    pub fn totals(&self) -> (u64, usize) {
139        let inner = self.lock();
140        (inner.total, inner.cache.len())
141    }
142
143    /// Take the set of mirrors needing (re)measurement, clearing it.
144    fn take_dirty(&self) -> Vec<String> {
145        self.lock().dirty.drain().collect()
146    }
147
148    /// Set a mirror's measured size, adjusting the running total. Called by the
149    /// background task after walking the mirror. Uses `peek`/`peek_mut`, which leave
150    /// recency untouched - a background measurement is not an access.
151    fn set_size(&self, name: &str, size: u64) {
152        let mut inner = self.lock();
153        let Some(old) = inner.cache.peek(name).copied() else {
154            return; // evicted between mark and measure
155        };
156        inner.total = inner.total - old + size;
157        if let Some(v) = inner.cache.peek_mut(name) {
158            *v = size;
159        }
160        self.set_gauges(&inner);
161    }
162
163    /// Pop least-recently-used mirrors off the tail until the total would be back
164    /// under the cap, removing them from the index. Returns `(name, dir)` for each
165    /// so the caller can delete it from disk. Empty when already under cap.
166    fn take_victims(&self) -> Vec<(String, PathBuf)> {
167        let mut inner = self.lock();
168        let mut victims = Vec::new();
169        while inner.total > self.max_bytes {
170            let Some((name, size)) = inner.cache.pop_lru() else {
171                break;
172            };
173            inner.total -= size;
174            let dir = self.cache_root.join(&name);
175            victims.push((name, dir));
176        }
177        self.set_gauges(&inner);
178        victims
179    }
180
181    fn lock(&self) -> MutexGuard<'_, Inner> {
182        self.state.lock().expect("cache index lock")
183    }
184
185    fn set_gauges(&self, inner: &Inner) {
186        self.metrics.set_cache_size(inner.total, inner.cache.len());
187    }
188}
189
190/// Background maintenance task. Measures changed mirrors and evicts the LRU tail
191/// whenever the index signals work, and exits cleanly when `shutdown` fires (or its
192/// sender drops).
193pub async fn run(
194    cache: Arc<GitCache>,
195    index: Arc<CacheIndex>,
196    mut shutdown: watch::Receiver<bool>,
197) {
198    loop {
199        // Run first: covers the startup-over-cap permit and any signal that arrived
200        // while the previous pass ran (a stored `notify_one` permit makes the next
201        // wait return immediately, so no work is missed).
202        maintain(&cache, &index).await;
203        tokio::select! {
204            biased; // prefer shutdown over another pass when both are ready
205            _ = shutdown.changed() => break,
206            _ = index.work.notified() => {}
207        }
208    }
209    tracing::debug!("cache evictor stopped");
210}
211
212/// One maintenance pass: (re)measure changed mirrors, then evict the LRU tail until
213/// under the cap. All disk IO lives here, off the request path.
214async fn maintain(cache: &GitCache, index: &CacheIndex) {
215    let dirty = index.take_dirty();
216    if !dirty.is_empty() {
217        let dirs: Vec<(String, PathBuf)> = dirty
218            .into_iter()
219            .map(|n| {
220                let dir = index.cache_dir(&n);
221                (n, dir)
222            })
223            .collect();
224        // The walk is blocking; keep it off the runtime.
225        let measured = tokio::task::spawn_blocking(move || {
226            dirs.into_iter()
227                .map(|(name, dir)| (name, measure(&dir).0))
228                .collect::<Vec<_>>()
229        })
230        .await
231        .unwrap_or_default();
232        for (name, size) in measured {
233            index.set_size(&name, size);
234        }
235    }
236
237    for (name, dir) in index.take_victims() {
238        // An LFS object is a single immutable file: a plain unlink is enough (an open
239        // reader keeps the inode via POSIX unlink semantics), so it skips the mirror's
240        // rename-then-remove dance. A mirror goes through `GitCache::evict`, which
241        // serializes against an in-flight clone/fetch for that repo.
242        let result = if is_lfs_blob(&name) {
243            match tokio::fs::remove_file(&dir).await {
244                Ok(()) => Ok(()),
245                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
246                Err(e) => Err(anyhow::Error::from(e)),
247            }
248        } else {
249            cache.evict(&name, &dir).await
250        };
251        match result {
252            Ok(()) => {
253                index.metrics.record_eviction();
254                tracing::info!(entry = %name, "evicted idle cache entry");
255            }
256            // The entry is already out of the index; a failed unlink just leaves an
257            // untracked file/dir on disk, which the next startup scan picks back up.
258            Err(e) => tracing::warn!(entry = %name, error = %e, "evict failed"),
259        }
260    }
261}
262
263/// Whether a cache key names an LFS object (under the reserved store dir) rather than
264/// a bare mirror. Determines how [`maintain`] removes an evicted entry.
265fn is_lfs_blob(name: &str) -> bool {
266    name.split('/').next() == Some(crate::repo::LFS_OBJECTS_DIR)
267}
268
269/// A mirror found on disk during the startup scan.
270struct Scanned {
271    name: String,
272    size: u64,
273    mtime: SystemTime,
274}
275
276/// Discover every mirror under the cache root. Mirrors live at arbitrary depth
277/// (`resolve` maps `group/team/foo.git` onto nested dirs), so this descends the
278/// namespace dirs - via an explicit stack, not recursion - and stops at each mirror
279/// root (a dir with a top-level `HEAD` file, the same "initialised mirror" marker
280/// `ensure_fresh` uses). Reserved staging/trash dirs are skipped so they never
281/// count toward the budget. Startup-only; steady state is the in-memory index. It
282/// reads only the namespace dirs here; `measure` reads inside each mirror, so the
283/// two never traverse the same directory twice.
284fn find_mirrors(cache_root: &Path) -> Vec<Scanned> {
285    let mut out = Vec::new();
286    let mut stack = vec![cache_root.to_path_buf()];
287    while let Some(dir) = stack.pop() {
288        if dir.join("HEAD").is_file() {
289            let name = rel_name(cache_root, &dir);
290            if name.is_empty() {
291                continue; // the cache root itself is not a mirror
292            }
293            let (size, mtime) = measure(&dir);
294            out.push(Scanned { name, size, mtime });
295            continue; // a mirror's subdirs are not themselves mirrors
296        }
297        let Ok(entries) = std::fs::read_dir(&dir) else {
298            continue;
299        };
300        for entry in entries.flatten() {
301            let Ok(ft) = entry.file_type() else { continue };
302            if !ft.is_dir() {
303                continue;
304            }
305            let fname = entry.file_name();
306            let fname = fname.to_string_lossy();
307            if fname.ends_with(crate::repo::INCOMING_SUFFIX)
308                || fname.ends_with(crate::repo::EVICTING_SUFFIX)
309                || fname == crate::repo::LFS_OBJECTS_DIR
310            {
311                continue; // the LFS store is scanned separately by `find_lfs_blobs`
312            }
313            stack.push(entry.path());
314        }
315    }
316    out
317}
318
319/// Discover every cached LFS object under the reserved store (`<root>/.__lfs__/<shard>/
320/// <oid>`, two levels deep). Each object is one immutable file whose size is known
321/// from its metadata, so - unlike a mirror - it needs no later re-measurement. The
322/// in-flight-download subdir is skipped. Startup-only; steady state uses `record_blob`.
323fn find_lfs_blobs(cache_root: &Path) -> Vec<Scanned> {
324    let mut out = Vec::new();
325    let lfs_root = cache_root.join(crate::repo::LFS_OBJECTS_DIR);
326    let Ok(shards) = std::fs::read_dir(&lfs_root) else {
327        return out; // no LFS store yet
328    };
329    for shard in shards.flatten() {
330        let Ok(ft) = shard.file_type() else { continue };
331        if !ft.is_dir() || shard.file_name().to_string_lossy() == crate::lfs::INCOMING_DIR {
332            continue;
333        }
334        let shard_name = shard.file_name().to_string_lossy().into_owned();
335        let Ok(objects) = std::fs::read_dir(shard.path()) else {
336            continue;
337        };
338        for object in objects.flatten() {
339            let Ok(md) = object.metadata() else { continue };
340            if !md.is_file() {
341                continue;
342            }
343            let oid = object.file_name().to_string_lossy().into_owned();
344            out.push(Scanned {
345                name: format!("{}/{shard_name}/{oid}", crate::repo::LFS_OBJECTS_DIR),
346                size: md.len(),
347                mtime: md.modified().unwrap_or(SystemTime::UNIX_EPOCH),
348            });
349        }
350    }
351    out
352}
353
354/// A mirror's cache-key name: its path relative to the root, `/`-joined so it
355/// matches the key `resolve` produces regardless of the platform separator.
356fn rel_name(root: &Path, dir: &Path) -> String {
357    dir.strip_prefix(root)
358        .unwrap_or(dir)
359        .components()
360        .map(|c| c.as_os_str().to_string_lossy())
361        .collect::<Vec<_>>()
362        .join("/")
363}
364
365/// Total byte size of a mirror and the newest mtime among its files. Reused for the
366/// startup scan and the background size refresh. A bare mirror is a handful of
367/// (mostly packed) files, so the walk cost tracks file count, not bytes.
368pub(crate) fn measure(dir: &Path) -> (u64, SystemTime) {
369    let mut size = 0u64;
370    let mut mtime = SystemTime::UNIX_EPOCH;
371    let mut stack = vec![dir.to_path_buf()];
372    while let Some(d) = stack.pop() {
373        let Ok(entries) = std::fs::read_dir(&d) else {
374            continue;
375        };
376        for entry in entries.flatten() {
377            let Ok(md) = entry.metadata() else { continue };
378            if md.is_dir() {
379                stack.push(entry.path());
380            } else {
381                size += md.len();
382                if let Ok(mt) = md.modified()
383                    && mt > mtime
384                {
385                    mtime = mt;
386                }
387            }
388        }
389    }
390    (size, mtime)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use std::io::Write;
397    use std::time::Duration;
398
399    use tokio::sync::watch;
400
401    use crate::git::{GitCache, GitConfig};
402
403    #[test]
404    fn size_accounting_tracks_the_total() {
405        let tmp = tempfile::tempdir().unwrap();
406        let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
407        idx.mark_changed("a"); // placeholder, size 0
408        idx.set_size("a", 100);
409        idx.mark_changed("b");
410        idx.set_size("b", 50);
411        assert_eq!(idx.totals(), (150, 2));
412        idx.set_size("a", 200); // re-measure in place, not a new entry
413        assert_eq!(idx.totals(), (250, 2));
414    }
415
416    #[test]
417    fn victims_pop_oldest_first_until_under_cap() {
418        let tmp = tempfile::tempdir().unwrap();
419        let root = tmp.path();
420        let now = SystemTime::now();
421        make_mirror(
422            &root.join("old.git"),
423            4096,
424            Some(now - Duration::from_secs(120)),
425        );
426        make_mirror(
427            &root.join("mid.git"),
428            4096,
429            Some(now - Duration::from_secs(60)),
430        );
431        make_mirror(&root.join("new.git"), 4096, Some(now));
432
433        // Each mirror is ~4 KiB; a 6000-byte cap leaves room for one, so the two
434        // oldest are popped, oldest first.
435        let idx = CacheIndex::new(root.to_path_buf(), 6000, Arc::new(Metrics::new()));
436        let names: Vec<String> = idx.take_victims().into_iter().map(|(n, _)| n).collect();
437        assert_eq!(names, vec!["old.git".to_string(), "mid.git".to_string()]);
438        assert_eq!(idx.totals().1, 1); // one mirror left in the index
439    }
440
441    #[test]
442    fn touch_promotes_and_spares_from_eviction() {
443        let tmp = tempfile::tempdir().unwrap();
444        let root = tmp.path();
445        let now = SystemTime::now();
446        make_mirror(
447            &root.join("old.git"),
448            4096,
449            Some(now - Duration::from_secs(120)),
450        );
451        make_mirror(
452            &root.join("mid.git"),
453            4096,
454            Some(now - Duration::from_secs(60)),
455        );
456        make_mirror(&root.join("new.git"), 4096, Some(now));
457
458        let idx = CacheIndex::new(root.to_path_buf(), 6000, Arc::new(Metrics::new()));
459        idx.touch("old.git"); // now the most-recently-used, must be spared
460        let names: Vec<String> = idx.take_victims().into_iter().map(|(n, _)| n).collect();
461        assert_eq!(names, vec!["mid.git".to_string(), "new.git".to_string()]);
462    }
463
464    #[tokio::test]
465    async fn maintain_measures_then_evicts_on_disk() {
466        let tmp = tempfile::tempdir().unwrap();
467        let root = tmp.path();
468        // Empty index, tiny cap; a single mirror appears on disk and is flagged.
469        make_mirror(&root.join("big.git"), 8192, None);
470
471        let metrics = Arc::new(Metrics::new());
472        let idx = CacheIndex::new(root.to_path_buf(), 4096, metrics.clone());
473        let cache = GitCache::new(dummy_cfg(), metrics.clone(), Some(idx.clone()));
474        idx.mark_changed("big.git"); // request path would do this after a clone
475
476        maintain(&cache, &idx).await; // measures big.git (>cap) then evicts it
477
478        assert!(
479            !root.join("big.git").exists(),
480            "over-cap mirror should be evicted"
481        );
482        assert_eq!(idx.totals(), (0, 0));
483        assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
484    }
485
486    #[tokio::test]
487    async fn run_evicts_over_cap_then_stops_on_shutdown() {
488        let tmp = tempfile::tempdir().unwrap();
489        let root = tmp.path();
490        let now = SystemTime::now();
491        make_mirror(
492            &root.join("old.git"),
493            4096,
494            Some(now - Duration::from_secs(120)),
495        );
496        make_mirror(&root.join("new.git"), 4096, None);
497
498        let metrics = Arc::new(Metrics::new());
499        let idx = CacheIndex::new(root.to_path_buf(), 6000, metrics.clone()); // over cap
500        let cache = Arc::new(GitCache::new(
501            dummy_cfg(),
502            metrics.clone(),
503            Some(idx.clone()),
504        ));
505
506        let (shutdown_tx, shutdown_rx) = watch::channel(false);
507        let handle = tokio::spawn(run(cache, idx.clone(), shutdown_rx));
508        // `run` drains once before its first `select`, so the eviction completes
509        // before the task can observe shutdown; awaiting the handle after signalling
510        // guarantees the drain ran and the loop exited cleanly.
511        shutdown_tx.send(true).unwrap();
512        handle.await.unwrap();
513
514        assert!(!root.join("old.git").exists(), "oldest mirror evicted");
515        assert!(root.join("new.git").exists(), "newest mirror kept");
516        assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
517    }
518
519    #[test]
520    fn set_size_ignores_an_untracked_mirror() {
521        let tmp = tempfile::tempdir().unwrap();
522        let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
523        // No entry for this name (e.g. evicted between mark and measure): no-op.
524        idx.set_size("never-tracked", 999);
525        assert_eq!(idx.totals(), (0, 0));
526    }
527
528    #[test]
529    fn scan_skips_stray_files_and_reserved_dirs() {
530        let tmp = tempfile::tempdir().unwrap();
531        let root = tmp.path();
532        make_mirror(&root.join("good.git"), 1024, None);
533        // A non-directory entry at the root must be ignored by the walk.
534        std::fs::write(root.join("stray.txt"), b"x").unwrap();
535        // Crashed clone/eviction leftovers must not be scanned as mirrors.
536        make_mirror(
537            &root.join(format!("wip.git{}", crate::repo::INCOMING_SUFFIX)),
538            1024,
539            None,
540        );
541        make_mirror(
542            &root.join(format!("gone.git{}", crate::repo::EVICTING_SUFFIX)),
543            1024,
544            None,
545        );
546
547        let idx = CacheIndex::new(root.to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
548        assert_eq!(idx.totals().1, 1, "only the real mirror is tracked");
549    }
550
551    #[tokio::test]
552    async fn evict_is_a_noop_when_the_mirror_is_already_gone() {
553        let tmp = tempfile::tempdir().unwrap();
554        let cache = GitCache::new(dummy_cfg(), Arc::new(Metrics::new()), None);
555        // No `HEAD` at this path, so `evict` returns early without touching disk.
556        let dir = tmp.path().join("absent.git");
557        cache.evict("absent.git", &dir).await.unwrap();
558        assert!(!dir.exists());
559    }
560
561    #[test]
562    fn record_blob_tracks_exact_size_in_place() {
563        let tmp = tempfile::tempdir().unwrap();
564        let idx = CacheIndex::new(tmp.path().to_path_buf(), u64::MAX, Arc::new(Metrics::new()));
565        let key = format!("{}/ab/oid", crate::repo::LFS_OBJECTS_DIR);
566        idx.record_blob(&key, 500);
567        assert_eq!(idx.totals(), (500, 1));
568        // Re-storing the same oid updates its size rather than double-counting.
569        idx.record_blob(&key, 700);
570        assert_eq!(idx.totals(), (700, 1));
571    }
572
573    #[tokio::test]
574    async fn lfs_blobs_are_scanned_at_startup_and_evicted_over_cap() {
575        let tmp = tempfile::tempdir().unwrap();
576        let root = tmp.path();
577        // A cached LFS object on disk under the reserved store, sharded by oid[..2].
578        let oid = format!("ab{}", "c".repeat(62));
579        let blob = root
580            .join(crate::repo::LFS_OBJECTS_DIR)
581            .join("ab")
582            .join(&oid);
583        std::fs::create_dir_all(blob.parent().unwrap()).unwrap();
584        // A stray file in the in-flight-download dir must be ignored by the scan.
585        let incoming = root
586            .join(crate::repo::LFS_OBJECTS_DIR)
587            .join(crate::lfs::INCOMING_DIR);
588        std::fs::create_dir_all(&incoming).unwrap();
589        write_file(&incoming.join("half"), &[b'x'; 10], None);
590        write_file(&blob, &vec![b'x'; 4096], None);
591
592        let metrics = Arc::new(Metrics::new());
593        // Cap below the blob: the startup scan tracks it, then it is over cap.
594        let idx = CacheIndex::new(root.to_path_buf(), 1000, metrics.clone());
595        assert_eq!(
596            idx.totals(),
597            (4096, 1),
598            "only the blob is tracked, not the in-flight file"
599        );
600
601        let cache = GitCache::new(dummy_cfg(), metrics.clone(), Some(idx.clone()));
602        maintain(&cache, &idx).await; // over cap -> the blob file is unlinked
603        assert!(!blob.exists(), "over-cap LFS blob should be evicted");
604        assert_eq!(idx.totals(), (0, 0));
605        assert!(metrics.gather().contains("gitcacheproxy_evictions_total 1"));
606    }
607
608    fn dummy_cfg() -> GitConfig {
609        // Eviction never shells out to git, so the binary is irrelevant.
610        GitConfig {
611            git_binary: "git".into(),
612            upstream_auth_header: None,
613            fetch_ttl: Duration::from_secs(10),
614        }
615    }
616
617    /// A fake bare mirror: `HEAD` plus a data file summing to at least `data_bytes`.
618    /// When `mtime` is set, both files are stamped with it so the mirror's last-used
619    /// signal is deterministic.
620    fn make_mirror(dir: &Path, data_bytes: usize, mtime: Option<SystemTime>) {
621        std::fs::create_dir_all(dir.join("objects")).unwrap();
622        write_file(&dir.join("HEAD"), b"ref: refs/heads/main\n", mtime);
623        write_file(
624            &dir.join("objects/pack.data"),
625            &vec![b'x'; data_bytes],
626            mtime,
627        );
628    }
629
630    fn write_file(path: &Path, bytes: &[u8], mtime: Option<SystemTime>) {
631        let mut f = std::fs::File::create(path).unwrap();
632        f.write_all(bytes).unwrap();
633        if let Some(t) = mtime {
634            f.set_modified(t).unwrap();
635        }
636    }
637}