Skip to main content

khive_pack_git/
cache.rs

1//! Scratch-clone cache for `git.digest`'s remote-URL mode (ADR-088
2//! Amendment 1). Clones/fetches into
3//! `~/.khive/scratch/git-digest/<cache_key>/`, keyed by canonical URL
4//! (`crate::source::cache_key`). An LRU cap (env-var configured:
5//! `KHIVE_GIT_DIGEST_CACHE_MAX_REPOS`, `KHIVE_GIT_DIGEST_CACHE_MAX_BYTES`,
6//! `KHIVE_GIT_DIGEST_CLONE_MAX_BYTES`, `KHIVE_GIT_DIGEST_SCRATCH_ROOT`)
7//! evicts least-recently-used clones once the cache exceeds its repo-count
8//! or total-byte limit; a per-clone size cap rejects an oversized
9//! clone/fetch before it enters the addressable cache slot. See
10//! crates/khive-pack-git/docs/api/cache.md for the full design rationale
11//! (ownership-proof eviction, staging-then-move installation, per-clone cap
12//! enforcement).
13
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use std::time::SystemTime;
17
18use uuid::Uuid;
19
20use crate::source::cache_key;
21
22pub const DEFAULT_MAX_REPOS: usize = 5;
23pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
24pub const DEFAULT_CLONE_MAX_BYTES: u64 = 1024 * 1024 * 1024;
25
26const MARKER_FILE: &str = ".khive-last-used";
27
28#[derive(Debug)]
29pub enum CacheError {
30    Io(std::io::Error),
31    Git(String),
32    CloneTooLarge {
33        bytes: u64,
34        cap: u64,
35    },
36    /// A repair operation would have to touch a path that does not prove
37    /// itself an owned cache slot. See
38    /// crates/khive-pack-git/docs/api/cache.md#cacheerrorunsafetoreplace.
39    UnsafeToReplace(PathBuf),
40}
41
42impl std::fmt::Display for CacheError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            CacheError::Io(e) => write!(f, "scratch-cache I/O error: {e}"),
46            CacheError::Git(msg) => write!(f, "{msg}"),
47            CacheError::CloneTooLarge { bytes, cap } => write!(
48                f,
49                "clone exceeds the per-clone size cap ({bytes} bytes > {cap} bytes); \
50                 the clone was removed. Raise KHIVE_GIT_DIGEST_CLONE_MAX_BYTES if this \
51                 repository's history is legitimately this large."
52            ),
53            CacheError::UnsafeToReplace(path) => write!(
54                f,
55                "refusing to replace {} -- it does not prove itself an owned cache slot",
56                path.display()
57            ),
58        }
59    }
60}
61
62impl std::error::Error for CacheError {}
63
64impl From<std::io::Error> for CacheError {
65    fn from(e: std::io::Error) -> Self {
66        CacheError::Io(e)
67    }
68}
69
70fn scratch_root() -> PathBuf {
71    if let Ok(over) = std::env::var("KHIVE_GIT_DIGEST_SCRATCH_ROOT") {
72        if !over.is_empty() {
73            return PathBuf::from(over);
74        }
75    }
76    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
77    PathBuf::from(home)
78        .join(".khive")
79        .join("scratch")
80        .join("git-digest")
81}
82
83fn env_u64(key: &str, default: u64) -> u64 {
84    std::env::var(key)
85        .ok()
86        .and_then(|v| v.parse().ok())
87        .unwrap_or(default)
88}
89
90fn max_repos() -> usize {
91    std::env::var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS")
92        .ok()
93        .and_then(|v| v.parse().ok())
94        .unwrap_or(DEFAULT_MAX_REPOS)
95}
96
97fn max_total_bytes() -> u64 {
98    env_u64("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", DEFAULT_MAX_TOTAL_BYTES)
99}
100
101fn clone_max_bytes() -> u64 {
102    env_u64("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES", DEFAULT_CLONE_MAX_BYTES)
103}
104
105/// Ensure a local clone of `canonical_url` exists and is up to date; returns
106/// the repo's local path.
107///
108/// Fetches into the existing slot if one already proves itself owned
109/// (`is_owned_entry`); otherwise clones fresh into a private staging
110/// directory, enforces the per-clone size cap, and only then moves it into
111/// the addressable cache slot. Returns `CacheError::UnsafeToReplace` if a
112/// non-owned directory already occupies the cache-key path, and
113/// `CacheError::CloneTooLarge` if the clone/fetch exceeds
114/// `digest_cache_clone_max_bytes`. Runs LRU eviction over the rest of the
115/// cache after a successful clone/fetch (this clone is exempt from its own
116/// eviction pass). See crates/khive-pack-git/docs/api/cache.md#ensure_clone for
117/// the staging-then-move and ownership-guard rationale.
118pub fn ensure_clone(canonical_url: &str) -> Result<PathBuf, CacheError> {
119    let root = scratch_root();
120    std::fs::create_dir_all(&root)?;
121    let key = cache_key(canonical_url);
122    let repo_dir = root.join(&key);
123    let cap = clone_max_bytes();
124
125    if repo_dir.join(".git").exists() {
126        if !is_owned_entry(&repo_dir) {
127            return Err(CacheError::UnsafeToReplace(repo_dir));
128        }
129        fetch(&repo_dir)?;
130        // `repo_dir` was just fetched into and its ownership already
131        // confirmed above; it vanishing here is a real problem (e.g. a
132        // racing, non-serialized `ensure_clone`/`reclone` on the same key --
133        // see `refetch_clone`'s doc comment), not a maybe-absent slot, so
134        // propagate rather than swallow.
135        let size = dir_size(&repo_dir)?;
136        if size > cap {
137            remove_owned_entry(&root, &repo_dir)?;
138            return Err(CacheError::CloneTooLarge { bytes: size, cap });
139        }
140        touch(&repo_dir)?;
141    } else {
142        install_fresh_clone(canonical_url, &root, &repo_dir, cap)?;
143    }
144
145    evict_lru(&root, &repo_dir)?;
146    Ok(repo_dir)
147}
148
149/// Re-fetch a corrupt-but-present cache slot with `git fetch --refetch`
150/// (issue #765), re-checking ownership immediately before fetching. See
151/// crates/khive-pack-git/docs/api/cache.md#refetch_clone.
152pub(crate) fn refetch_clone(canonical_url: &str) -> Result<PathBuf, CacheError> {
153    let root = scratch_root();
154    let key = cache_key(canonical_url);
155    let repo_dir = root.join(&key);
156    if !repo_dir.join(".git").exists() {
157        return Err(CacheError::Git(format!(
158            "refetch requested for {canonical_url:?} but no cache slot exists at {}",
159            repo_dir.display()
160        )));
161    }
162    // Re-check ownership immediately before mutating the slot (issue #765
163    // follow-up PR #788) — see crates/khive-pack-git/docs/api/cache.md#refetch_clone.
164    if !is_owned_entry(&repo_dir) {
165        return Err(CacheError::UnsafeToReplace(repo_dir));
166    }
167
168    fetch_refetch(&repo_dir)?;
169
170    let cap = clone_max_bytes();
171    let size = dir_size(&repo_dir)?;
172    if size > cap {
173        // Ownership-guarded removal, not a raw `remove_dir_all` — see
174        // crates/khive-pack-git/docs/api/cache.md#refetch_clone.
175        remove_owned_entry(&root, &repo_dir)?;
176        return Err(CacheError::CloneTooLarge { bytes: size, cap });
177    }
178
179    touch(&repo_dir)?;
180    evict_lru(&root, &repo_dir)?;
181    Ok(repo_dir)
182}
183
184/// Evict an owned cache slot (if present) and install a fresh clone in its
185/// place (issue #765's fallback when a refetch cannot repair the slot). See
186/// crates/khive-pack-git/docs/api/cache.md#reclone.
187pub(crate) fn reclone(canonical_url: &str) -> Result<PathBuf, CacheError> {
188    let root = scratch_root();
189    std::fs::create_dir_all(&root)?;
190    let key = cache_key(canonical_url);
191    let repo_dir = root.join(&key);
192    let cap = clone_max_bytes();
193
194    remove_owned_entry(&root, &repo_dir)?;
195    install_fresh_clone(canonical_url, &root, &repo_dir, cap)?;
196
197    evict_lru(&root, &repo_dir)?;
198    Ok(repo_dir)
199}
200
201/// Shared staging-clone-then-move path for both a first-time `ensure_clone`
202/// and a `reclone` repair. See
203/// crates/khive-pack-git/docs/api/cache.md#install_fresh_clone.
204fn install_fresh_clone(
205    canonical_url: &str,
206    root: &Path,
207    repo_dir: &Path,
208    cap: u64,
209) -> Result<(), CacheError> {
210    let staging_dir = root.join(format!(".staging-{}", Uuid::new_v4()));
211    clone(canonical_url, &staging_dir).inspect_err(|_| {
212        // `git clone` can create and partially populate the destination
213        // before failing (network drop, auth failure, bad ref) -- clean
214        // it up so a run of failures doesn't leave `.staging-*` litter
215        // under the scratch root. `evict_lru` deliberately never touches
216        // non-owned names (`is_owned_entry`), so nothing else would ever
217        // reclaim this on its own.
218        let _ = std::fs::remove_dir_all(&staging_dir);
219    })?;
220    let size = dir_size(&staging_dir).inspect_err(|_| {
221        let _ = std::fs::remove_dir_all(&staging_dir);
222    })?;
223    if size > cap {
224        let _ = std::fs::remove_dir_all(&staging_dir);
225        return Err(CacheError::CloneTooLarge { bytes: size, cap });
226    }
227    touch(&staging_dir).inspect_err(|_| {
228        let _ = std::fs::remove_dir_all(&staging_dir);
229    })?;
230    std::fs::rename(&staging_dir, repo_dir).map_err(|e| {
231        let _ = std::fs::remove_dir_all(&staging_dir);
232        CacheError::Io(e)
233    })?;
234    Ok(())
235}
236
237/// Remove `repo_dir` only when it is a direct child of `root` AND passes
238/// `is_owned_entry`. A slot that does not currently exist is not an error.
239fn remove_owned_entry(root: &Path, repo_dir: &Path) -> Result<(), CacheError> {
240    if !repo_dir.exists() {
241        return Ok(());
242    }
243    if repo_dir.parent() != Some(root) || !is_owned_entry(repo_dir) {
244        return Err(CacheError::UnsafeToReplace(repo_dir.to_path_buf()));
245    }
246    remove_dir_all_retrying(repo_dir).map_err(CacheError::Io)?;
247    Ok(())
248}
249
250/// Retries `remove_dir_all` a few times before giving up — see
251/// crates/khive-pack-git/docs/api/cache.md#remove_dir_all_retrying.
252fn remove_dir_all_retrying(path: &Path) -> std::io::Result<()> {
253    let mut last_err = None;
254    for attempt in 0..5 {
255        match std::fs::remove_dir_all(path) {
256            Ok(()) => return Ok(()),
257            Err(e) => {
258                last_err = Some(e);
259                if attempt < 4 {
260                    std::thread::sleep(std::time::Duration::from_millis(20));
261                }
262            }
263        }
264    }
265    Err(last_err.expect("loop always sets last_err before exiting"))
266}
267
268/// `-c maintenance.auto=false` on every clone/fetch into a cache slot: git
269/// can otherwise spawn a detached background maintenance child that mutates
270/// the slot's `.git` tree concurrently with a `dir_size`/`evict_lru` walk
271/// (issue #842 flake family). See
272/// crates/khive-pack-git/docs/api/cache.md#clone-git-subprocess-maintenanceautofalse.
273fn clone(url: &str, dest: &Path) -> Result<(), CacheError> {
274    let status = Command::new("git")
275        .arg("-c")
276        .arg("core.hooksPath=/dev/null")
277        .arg("-c")
278        .arg("gc.auto=0")
279        .arg("-c")
280        .arg("maintenance.auto=false")
281        .arg("clone")
282        .arg("--filter=blob:none")
283        .arg(url)
284        .arg(dest)
285        .env("GIT_TERMINAL_PROMPT", "0")
286        .status()
287        .map_err(|e| CacheError::Git(format!("spawning git clone: {e}")))?;
288    if !status.success() {
289        return Err(CacheError::Git(format!(
290            "git clone {url} failed (exit {status})"
291        )));
292    }
293    Ok(())
294}
295
296fn fetch(repo: &Path) -> Result<(), CacheError> {
297    let status = Command::new("git")
298        .arg("-c")
299        .arg("core.hooksPath=/dev/null")
300        .arg("-c")
301        .arg("gc.auto=0")
302        .arg("-c")
303        .arg("maintenance.auto=false")
304        .arg("-C")
305        .arg(repo)
306        .arg("fetch")
307        .arg("--prune")
308        .env("GIT_TERMINAL_PROMPT", "0")
309        .status()
310        .map_err(|e| CacheError::Git(format!("spawning git fetch: {e}")))?;
311    if !status.success() {
312        return Err(CacheError::Git(format!(
313            "git fetch in {} failed (exit {status})",
314            repo.display()
315        )));
316    }
317    Ok(())
318}
319
320/// Issue #765 repair primitive: `git fetch --refetch origin` obtains a
321/// complete fresh filtered packfile instead of incrementally trusting the
322/// existing object store.
323fn fetch_refetch(repo: &Path) -> Result<(), CacheError> {
324    let status = Command::new("git")
325        .arg("-c")
326        .arg("core.hooksPath=/dev/null")
327        .arg("-c")
328        .arg("gc.auto=0")
329        .arg("-c")
330        .arg("maintenance.auto=false")
331        .arg("-C")
332        .arg(repo)
333        .arg("fetch")
334        .arg("--refetch")
335        .arg("origin")
336        .env("GIT_TERMINAL_PROMPT", "0")
337        .status()
338        .map_err(|e| CacheError::Git(format!("spawning git fetch --refetch: {e}")))?;
339    if !status.success() {
340        return Err(CacheError::Git(format!(
341            "git fetch --refetch in {} failed (exit {status})",
342            repo.display()
343        )));
344    }
345    Ok(())
346}
347
348/// Wraps an I/O error with the operation and path it happened on.
349fn io_err(op: &str, path: &Path, e: std::io::Error) -> CacheError {
350    CacheError::Io(std::io::Error::new(
351        e.kind(),
352        format!("{op} {}: {e}", path.display()),
353    ))
354}
355
356fn touch(repo_dir: &Path) -> Result<(), CacheError> {
357    let marker = repo_dir.join(MARKER_FILE);
358    std::fs::write(&marker, b"").map_err(|e| io_err("touch: write marker", &marker, e))?;
359    Ok(())
360}
361
362/// Recursive directory size, following no symlinks. Tolerant of a
363/// *descendant* disappearing mid-walk (contributes 0 bytes); the walk
364/// **root** itself vanishing is NOT tolerated and surfaces as
365/// `CacheError::Io(NotFound)`. See
366/// crates/khive-pack-git/docs/api/cache.md#dir_size.
367fn dir_size(path: &Path) -> Result<u64, CacheError> {
368    let mut total = 0u64;
369    let mut stack = vec![path.to_path_buf()];
370    while let Some(p) = stack.pop() {
371        let is_root = p == path;
372        let md = match std::fs::symlink_metadata(&p) {
373            Ok(md) => md,
374            Err(e) if e.kind() == std::io::ErrorKind::NotFound && !is_root => continue,
375            Err(e) => return Err(io_err("dir_size: stat", &p, e)),
376        };
377        if md.is_dir() {
378            let read_dir = match std::fs::read_dir(&p) {
379                Ok(read_dir) => read_dir,
380                Err(e) if e.kind() == std::io::ErrorKind::NotFound && !is_root => continue,
381                Err(e) => return Err(io_err("dir_size: read_dir", &p, e)),
382            };
383            for entry in read_dir {
384                match entry {
385                    Ok(entry) => stack.push(entry.path()),
386                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
387                    Err(e) => return Err(io_err("dir_size: read_dir entry", &p, e)),
388                }
389            }
390        } else {
391            total += md.len();
392        }
393    }
394    Ok(total)
395}
396
397/// Whether `path` is a directory `ensure_clone` could plausibly have
398/// created: a 16-lowercase-hex `cache_key`-shaped real directory (not a
399/// symlink) containing both a `.git` entry and the `.khive-last-used`
400/// marker. See crates/khive-pack-git/docs/api/cache.md#is_owned_entry.
401fn is_owned_entry(path: &Path) -> bool {
402    let name = match path.file_name().and_then(|n| n.to_str()) {
403        Some(n) => n,
404        None => return false,
405    };
406    if name.len() != 16
407        || !name
408            .chars()
409            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
410    {
411        return false;
412    }
413    match std::fs::symlink_metadata(path) {
414        Ok(md) if md.is_dir() => {}
415        _ => return false,
416    }
417    path.join(".git").exists() && path.join(MARKER_FILE).exists()
418}
419
420/// Evict least-recently-used clones under `root` until both the
421/// repo-count cap and the total-byte cap are satisfied. `keep` is never
422/// evicted, and its own vanishing is NOT tolerated (propagates as an
423/// error); a listed candidate entry vanishing mid-walk IS tolerated
424/// (skipped). See crates/khive-pack-git/docs/api/cache.md#evict_lru.
425fn evict_lru(root: &Path, keep: &Path) -> Result<(), CacheError> {
426    let mut entries: Vec<(PathBuf, SystemTime, u64)> = Vec::new();
427    let read_dir =
428        std::fs::read_dir(root).map_err(|e| io_err("evict_lru: read_dir root", root, e))?;
429    for entry in read_dir {
430        let entry = match entry {
431            Ok(entry) => entry,
432            // The directory listing raced a concurrent removal of one of its
433            // own entries (e.g. another `evict_lru`/`ensure_clone` repairing
434            // the same root) -- nothing to evict there anymore.
435            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
436            Err(e) => return Err(io_err("evict_lru: read_dir entry", root, e)),
437        };
438        let p = entry.path();
439        if !p.is_dir() || p == keep || !is_owned_entry(&p) {
440            continue;
441        }
442        let mtime = std::fs::metadata(p.join(MARKER_FILE))
443            .and_then(|m| m.modified())
444            .unwrap_or(SystemTime::UNIX_EPOCH);
445        let size = match dir_size(&p) {
446            Ok(size) => size,
447            // `p` was listed above but a concurrent repair on the same root
448            // has since deleted it whole -- there is no slot left to weigh
449            // in eviction accounting, not a size of `0` to record.
450            Err(CacheError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => continue,
451            Err(e) => return Err(e),
452        };
453        entries.push((p, mtime, size));
454    }
455    entries.sort_by_key(|(_, mtime, _)| *mtime);
456
457    let keep_size = dir_size(keep)?;
458    let mut total: u64 = entries.iter().map(|(_, _, s)| s).sum::<u64>() + keep_size;
459    let mut count = entries.len() + 1;
460    let cap_repos = max_repos();
461    let cap_bytes = max_total_bytes();
462
463    for (path, _, size) in entries {
464        if count <= cap_repos && total <= cap_bytes {
465            break;
466        }
467        let _ = std::fs::remove_dir_all(&path);
468        count -= 1;
469        total = total.saturating_sub(size);
470    }
471    Ok(())
472}
473
474/// Serializes tests that touch process-global env vars (`scratch_root()`
475/// reads them). See crates/khive-pack-git/docs/api/cache.md#env_mutex.
476#[cfg(test)]
477pub(crate) static ENV_MUTEX: std::sync::LazyLock<tokio::sync::Mutex<()>> =
478    std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    /// Build a directory shaped exactly like a real `ensure_clone` cache slot.
485    fn make_owned_entry(root: &Path, key: &str, with_marker: bool) -> PathBuf {
486        assert_eq!(key.len(), 16, "test cache keys must be 16 hex chars");
487        let p = root.join(key);
488        std::fs::create_dir_all(p.join(".git")).unwrap();
489        if with_marker {
490            std::fs::write(p.join(MARKER_FILE), b"").unwrap();
491        }
492        p
493    }
494
495    /// A `git clone` failure must not leave a `.staging-<uuid>` dir behind.
496    #[test]
497    fn ensure_clone_cleans_up_staging_dir_on_clone_failure() {
498        let _guard = ENV_MUTEX.blocking_lock();
499        let dir = tempfile::tempdir().expect("tempdir");
500        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", dir.path());
501
502        let bogus_source = dir.path().join("does-not-exist-as-a-repo");
503        let result = ensure_clone(bogus_source.to_str().expect("utf8 path"));
504        assert!(
505            result.is_err(),
506            "cloning a nonexistent local path must fail: {result:?}"
507        );
508
509        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
510            .expect("read scratch root")
511            .filter_map(|e| e.ok())
512            .map(|e| e.file_name().to_string_lossy().into_owned())
513            .filter(|name| name.starts_with(".staging-"))
514            .collect();
515        assert!(
516            leftovers.is_empty(),
517            "a failed clone must not leave .staging-* directories behind: {leftovers:?}"
518        );
519
520        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
521    }
522
523    #[test]
524    fn evict_lru_removes_oldest_past_repo_cap() {
525        let _guard = ENV_MUTEX.blocking_lock();
526        let dir = tempfile::tempdir().expect("tempdir");
527        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", dir.path());
528        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "1");
529        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
530
531        let root = dir.path();
532        let old = make_owned_entry(root, "1111111111111111", true);
533        // Ensure a real mtime gap.
534        std::thread::sleep(std::time::Duration::from_millis(20));
535        let new = make_owned_entry(root, "2222222222222222", true);
536
537        evict_lru(root, &new).expect("evict");
538
539        assert!(!old.exists(), "the older clone must be evicted");
540        assert!(new.exists(), "the kept clone must survive");
541
542        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
543        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
544        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
545    }
546
547    #[test]
548    fn evict_lru_only_touches_children_of_root() {
549        let _guard = ENV_MUTEX.blocking_lock();
550        let dir = tempfile::tempdir().expect("tempdir");
551        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "5");
552        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
553
554        let root = dir.path().join("scratch-root");
555        std::fs::create_dir_all(&root).unwrap();
556        let kept = make_owned_entry(&root, "3333333333333333", true);
557
558        evict_lru(&root, &kept).expect("evict");
559        assert!(kept.exists());
560
561        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
562        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
563    }
564
565    #[test]
566    fn evict_lru_never_removes_a_foreign_directory_under_root() {
567        let _guard = ENV_MUTEX.blocking_lock();
568        let dir = tempfile::tempdir().expect("tempdir");
569        // Cap of 0 repos: without ownership filtering this would previously
570        // have wiped out every child of root, including operator data.
571        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "0");
572        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "0");
573
574        let root = dir.path().join("scratch-root");
575        std::fs::create_dir_all(&root).unwrap();
576        let foreign = root.join("not-a-cache-entry");
577        std::fs::create_dir_all(&foreign).unwrap();
578        std::fs::write(foreign.join("important.txt"), b"do not delete me").unwrap();
579        let kept = make_owned_entry(&root, "4444444444444444", true);
580
581        evict_lru(&root, &kept).expect("evict");
582
583        assert!(
584            foreign.exists(),
585            "a directory that doesn't look like a cache slot must survive eviction"
586        );
587        assert!(
588            foreign.join("important.txt").exists(),
589            "foreign directory contents must be untouched"
590        );
591
592        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
593        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
594    }
595
596    #[test]
597    fn evict_lru_never_removes_an_owned_looking_dir_missing_the_marker() {
598        let _guard = ENV_MUTEX.blocking_lock();
599        let dir = tempfile::tempdir().expect("tempdir");
600        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "0");
601        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "0");
602
603        let root = dir.path().join("scratch-root");
604        std::fs::create_dir_all(&root).unwrap();
605        // Has a .git dir and a valid cache-key-shaped name, but no marker --
606        // e.g. a clone that failed after `clone()` but before `touch()`.
607        let no_marker = make_owned_entry(&root, "5555555555555555", false);
608        let kept = make_owned_entry(&root, "6666666666666666", true);
609
610        evict_lru(&root, &kept).expect("evict");
611
612        assert!(
613            no_marker.exists(),
614            "an owned-looking directory without the marker must survive eviction"
615        );
616
617        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
618        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
619    }
620
621    #[test]
622    fn is_owned_entry_rejects_non_cache_shapes() {
623        let dir = tempfile::tempdir().expect("tempdir");
624        let root = dir.path();
625
626        // Wrong length.
627        let short = root.join("abc123");
628        std::fs::create_dir_all(short.join(".git")).unwrap();
629        std::fs::write(short.join(MARKER_FILE), b"").unwrap();
630        assert!(!is_owned_entry(&short));
631
632        // Uppercase hex (cache_key is always lowercase).
633        let upper = root.join("ABCDEF0123456789");
634        std::fs::create_dir_all(upper.join(".git")).unwrap();
635        std::fs::write(upper.join(MARKER_FILE), b"").unwrap();
636        assert!(!is_owned_entry(&upper));
637
638        // Right shape but missing .git.
639        let no_git = root.join("7777777777777777");
640        std::fs::create_dir_all(&no_git).unwrap();
641        std::fs::write(no_git.join(MARKER_FILE), b"").unwrap();
642        assert!(!is_owned_entry(&no_git));
643
644        let owned = make_owned_entry(root, "8888888888888888", true);
645        assert!(is_owned_entry(&owned));
646    }
647
648    #[test]
649    fn dir_size_sums_file_bytes_recursively() {
650        let dir = tempfile::tempdir().expect("tempdir");
651        std::fs::write(dir.path().join("a.txt"), b"12345").unwrap();
652        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
653        std::fs::write(dir.path().join("sub/b.txt"), b"1234567890").unwrap();
654        assert_eq!(dir_size(dir.path()).unwrap(), 15);
655    }
656
657    /// PR #847: walk root vanishing must error, never launder to `Ok(0)`.
658    #[test]
659    fn dir_size_errors_when_the_root_itself_is_missing() {
660        let dir = tempfile::tempdir().expect("tempdir");
661        let missing = dir.path().join("does-not-exist");
662        let err = dir_size(&missing).expect_err("a missing root must error, not size to 0");
663        assert!(
664            matches!(&err, CacheError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
665            "expected CacheError::Io(NotFound), got {err:?}"
666        );
667    }
668
669    /// `keep` vanishing must propagate, not be treated as an empty slot.
670    #[test]
671    fn evict_lru_errors_when_keep_itself_is_missing() {
672        let _guard = ENV_MUTEX.blocking_lock();
673        let dir = tempfile::tempdir().expect("tempdir");
674        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "5");
675        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
676
677        let root = dir.path().join("scratch-root");
678        std::fs::create_dir_all(&root).unwrap();
679        let missing_keep = root.join("0000000000000000");
680
681        let err = evict_lru(&root, &missing_keep).expect_err("a missing keep root must error");
682        assert!(
683            matches!(&err, CacheError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
684            "expected CacheError::Io(NotFound), got {err:?}"
685        );
686
687        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
688        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
689    }
690
691    /// Issue #842 macOS ENOENT flake family: a descendant disappearing
692    /// mid-walk must shrink the total, not abort with `NotFound`. See
693    /// crates/khive-pack-git/docs/api/cache.md#test-module-notes.
694    #[test]
695    fn dir_size_tolerates_a_subdirectory_removed_mid_walk() {
696        for _ in 0..200 {
697            let dir = tempfile::tempdir().expect("tempdir");
698            let root = dir.path().to_path_buf();
699            let victim = root.join("victim");
700            std::fs::create_dir_all(&victim).unwrap();
701            for i in 0..64 {
702                std::fs::write(victim.join(format!("f{i}.txt")), b"0123456789").unwrap();
703            }
704            // A wide fan of siblings so the walk still has entries left on
705            // its stack (and is plausibly still inside `victim`) at the
706            // instant the other thread deletes it.
707            for i in 0..64 {
708                let sibling = root.join(format!("sibling{i}"));
709                std::fs::create_dir_all(&sibling).unwrap();
710                std::fs::write(sibling.join("s.txt"), b"0123456789").unwrap();
711            }
712
713            let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
714            let walk_root = root.clone();
715            let walk_barrier = barrier.clone();
716            let walker = std::thread::spawn(move || {
717                walk_barrier.wait();
718                dir_size(&walk_root)
719            });
720            let delete_victim = victim.clone();
721            let deleter = std::thread::spawn(move || {
722                barrier.wait();
723                let _ = std::fs::remove_dir_all(&delete_victim);
724            });
725
726            let result = walker.join().expect("walker thread");
727            deleter.join().expect("deleter thread");
728
729            assert!(
730                result.is_ok(),
731                "dir_size must tolerate a subdirectory vanishing mid-walk, got {result:?}"
732            );
733        }
734    }
735
736    /// Companion to the test above (PR #847): the walk root itself
737    /// vanishing must error, not tolerate like a descendant. See
738    /// crates/khive-pack-git/docs/api/cache.md#test-module-notes.
739    #[test]
740    fn dir_size_errors_when_the_root_is_removed_mid_walk() {
741        let mut saw_error = false;
742        for _ in 0..500 {
743            let dir = tempfile::tempdir().expect("tempdir");
744            let root = dir.path().join("slot");
745            std::fs::create_dir_all(&root).unwrap();
746
747            let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
748            let walk_root = root.clone();
749            let walk_barrier = barrier.clone();
750            let walker = std::thread::spawn(move || {
751                walk_barrier.wait();
752                dir_size(&walk_root)
753            });
754            let delete_root = root.clone();
755            let deleter = std::thread::spawn(move || {
756                barrier.wait();
757                let _ = std::fs::remove_dir(&delete_root);
758            });
759
760            let result = walker.join().expect("walker thread");
761            deleter.join().expect("deleter thread");
762
763            match result {
764                Ok(_) => continue, // walker won the race this round; try again
765                Err(CacheError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
766                    saw_error = true;
767                }
768                Err(e) => panic!("unexpected error kind from a vanished root: {e:?}"),
769            }
770        }
771        assert!(
772            saw_error,
773            "root-vanish-mid-walk race was never hit across 500 iterations; \
774             widen the fixture or investigate the barrier timing"
775        );
776    }
777
778    // ── issue #765: refetch/reclone repair primitives ──────────────────────
779
780    fn git(repo: &Path, args: &[&str]) {
781        let out = Command::new("git")
782            .arg("-C")
783            .arg(repo)
784            .args(args)
785            .output()
786            .expect("spawn git");
787        assert!(
788            out.status.success(),
789            "git {args:?} failed: {}",
790            String::from_utf8_lossy(&out.stderr)
791        );
792    }
793
794    /// A real local repo usable as a `canonical_url` (git accepts a plain
795    /// filesystem path as a clone/fetch source).
796    fn init_origin_with_one_commit(repo: &Path) {
797        git(repo, &["init", "-q", "-b", "main"]);
798        git(repo, &["config", "user.email", "test@example.com"]);
799        git(repo, &["config", "user.name", "Test User"]);
800        std::fs::write(repo.join("a.txt"), b"hello").unwrap();
801        git(repo, &["add", "a.txt"]);
802        git(repo, &["commit", "-q", "-m", "initial"]);
803    }
804
805    fn add_commit(repo: &Path, rel: &str, contents: &str, message: &str) {
806        std::fs::write(repo.join(rel), contents).unwrap();
807        git(repo, &["add", rel]);
808        git(repo, &["commit", "-q", "-m", message]);
809    }
810
811    fn head_sha(repo: &Path) -> String {
812        let out = Command::new("git")
813            .arg("-C")
814            .arg(repo)
815            .args(["rev-parse", "HEAD"])
816            .output()
817            .expect("rev-parse");
818        String::from_utf8_lossy(&out.stdout).trim().to_string()
819    }
820
821    /// Primary #765 acceptance path — see
822    /// crates/khive-pack-git/docs/api/cache.md#test-module-notes.
823    #[test]
824    fn refetch_clone_updates_an_existing_slot_to_the_remote_tip() {
825        let _guard = ENV_MUTEX.blocking_lock();
826        let scratch = tempfile::tempdir().expect("tempdir");
827        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
828
829        let origin_dir = tempfile::tempdir().expect("tempdir");
830        init_origin_with_one_commit(origin_dir.path());
831        let canonical = origin_dir.path().to_str().unwrap();
832
833        let first = ensure_clone(canonical).expect("initial ensure_clone");
834        let before = head_sha(&first);
835
836        add_commit(origin_dir.path(), "b.txt", "world", "second");
837        let expected_tip = head_sha(origin_dir.path());
838        assert_ne!(before, expected_tip, "origin must have moved on");
839
840        let repaired = refetch_clone(canonical).expect("refetch_clone");
841        assert_eq!(repaired, first, "refetch repairs the same cache slot path");
842        git(&repaired, &["show", &format!("{expected_tip}:b.txt")]);
843
844        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
845    }
846
847    /// Remediation (issue #765) — see
848    /// crates/khive-pack-git/docs/api/cache.md#test-module-notes.
849    #[test]
850    fn refetch_clone_over_cap_cleanup_never_deletes_an_unproven_slot() {
851        let _guard = ENV_MUTEX.blocking_lock();
852        let scratch = tempfile::tempdir().expect("tempdir");
853        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
854
855        let origin_dir = tempfile::tempdir().expect("tempdir");
856        init_origin_with_one_commit(origin_dir.path());
857        let canonical = origin_dir.path().to_str().unwrap();
858
859        let slot = ensure_clone(canonical).expect("initial ensure_clone");
860        // Simulate a slot the ownership guard cannot prove it owns (e.g. a
861        // crash between a prior clone/fetch and `touch`, or a foreign
862        // directory occupying this exact cache-key path) by removing the
863        // marker `touch` would normally have written.
864        std::fs::remove_file(slot.join(".khive-last-used")).expect("remove marker");
865
866        std::env::set_var("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES", "1");
867        let err = refetch_clone(canonical).expect_err("refetch must report the ownership error");
868        assert!(
869            matches!(err, CacheError::UnsafeToReplace(_)),
870            "expected UnsafeToReplace (the cleanup's ownership failure, propagated), got {err:?}"
871        );
872        assert!(
873            slot.exists(),
874            "a slot the ownership guard cannot prove it owns must survive over-cap cleanup"
875        );
876
877        std::env::remove_var("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES");
878        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
879    }
880
881    /// Remediation (issue #765 follow-up PR #788) — see
882    /// crates/khive-pack-git/docs/api/cache.md#test-module-notes.
883    #[test]
884    fn refetch_clone_refuses_a_markerless_slot_under_the_cap() {
885        let _guard = ENV_MUTEX.blocking_lock();
886        let scratch = tempfile::tempdir().expect("tempdir");
887        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
888
889        let origin_dir = tempfile::tempdir().expect("tempdir");
890        init_origin_with_one_commit(origin_dir.path());
891        let canonical = origin_dir.path().to_str().unwrap();
892
893        let slot = ensure_clone(canonical).expect("initial ensure_clone");
894        let sentinel_sha = head_sha(&slot);
895        std::fs::remove_file(slot.join(MARKER_FILE)).expect("remove marker");
896
897        // The origin moves on -- if the ownership guard failed to fire and
898        // a real fetch ran, the slot's HEAD would follow.
899        add_commit(origin_dir.path(), "b.txt", "world", "second");
900
901        let err = refetch_clone(canonical)
902            .expect_err("a markerless slot must be refused before any fetch runs");
903        assert!(
904            matches!(err, CacheError::UnsafeToReplace(_)),
905            "expected UnsafeToReplace, got {err:?}"
906        );
907        assert_eq!(
908            head_sha(&slot),
909            sentinel_sha,
910            "no fetch must have run against the markerless slot"
911        );
912        assert!(
913            !slot.join(MARKER_FILE).exists(),
914            "a refused refetch must never (re)write the ownership marker"
915        );
916
917        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
918    }
919
920    #[test]
921    fn refetch_clone_errors_when_no_slot_exists() {
922        let _guard = ENV_MUTEX.blocking_lock();
923        let scratch = tempfile::tempdir().expect("tempdir");
924        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
925
926        let err = refetch_clone("https://example.invalid/never-cloned/repo")
927            .expect_err("no slot exists yet");
928        assert!(
929            matches!(err, CacheError::Git(_)),
930            "expected CacheError::Git, got {err:?}"
931        );
932
933        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
934    }
935
936    /// #765's fallback path — see
937    /// crates/khive-pack-git/docs/api/cache.md#test-module-notes.
938    #[test]
939    fn reclone_replaces_a_slot_whose_refetch_cannot_succeed() {
940        let _guard = ENV_MUTEX.blocking_lock();
941        let scratch = tempfile::tempdir().expect("tempdir");
942        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
943
944        let origin_dir = tempfile::tempdir().expect("tempdir");
945        init_origin_with_one_commit(origin_dir.path());
946        let canonical = origin_dir.path().to_str().unwrap();
947
948        let slot = ensure_clone(canonical).expect("initial ensure_clone");
949        // Break the slot's own remote so `fetch --refetch origin` fails --
950        // standing in for a corrupt slot that cannot self-repair via refetch.
951        git(
952            &slot,
953            &[
954                "remote",
955                "set-url",
956                "origin",
957                "/nonexistent/path/does-not-exist",
958            ],
959        );
960        assert!(matches!(refetch_clone(canonical), Err(CacheError::Git(_))));
961
962        let recloned = reclone(canonical).expect("reclone");
963        assert_eq!(recloned, slot, "reclone reinstalls at the same slot path");
964        assert_eq!(head_sha(&recloned), head_sha(origin_dir.path()));
965        // The fresh clone's own remote points back at the canonical URL, not
966        // the broken one the corrupt slot had.
967        let out = Command::new("git")
968            .arg("-C")
969            .arg(&recloned)
970            .args(["remote", "get-url", "origin"])
971            .output()
972            .expect("remote get-url");
973        assert_eq!(
974            String::from_utf8_lossy(&out.stdout).trim(),
975            canonical,
976            "reclone must re-point origin at canonical_url, not the broken remote"
977        );
978
979        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
980    }
981
982    /// Ownership guard (ADR-088 Amendment 1 / PR #761) — see
983    /// crates/khive-pack-git/docs/api/cache.md#test-module-notes.
984    #[test]
985    fn reclone_refuses_to_replace_a_foreign_looking_directory() {
986        let _guard = ENV_MUTEX.blocking_lock();
987        let scratch = tempfile::tempdir().expect("tempdir");
988        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
989
990        let origin_dir = tempfile::tempdir().expect("tempdir");
991        init_origin_with_one_commit(origin_dir.path());
992        let canonical = origin_dir.path().to_str().unwrap();
993        let key = cache_key(canonical);
994        let foreign = scratch.path().join(&key);
995        std::fs::create_dir_all(&foreign).unwrap();
996        std::fs::write(foreign.join("important.txt"), b"do not delete me").unwrap();
997
998        let err = reclone(canonical).expect_err("foreign directory must be refused");
999        assert!(
1000            matches!(err, CacheError::UnsafeToReplace(_)),
1001            "expected UnsafeToReplace, got {err:?}"
1002        );
1003        assert!(
1004            foreign.join("important.txt").exists(),
1005            "foreign directory contents must survive a refused reclone"
1006        );
1007
1008        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1009    }
1010
1011    /// No slot exists yet: `reclone` simply installs a fresh clone.
1012    #[test]
1013    fn reclone_installs_fresh_when_no_slot_exists_yet() {
1014        let _guard = ENV_MUTEX.blocking_lock();
1015        let scratch = tempfile::tempdir().expect("tempdir");
1016        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1017
1018        let origin_dir = tempfile::tempdir().expect("tempdir");
1019        init_origin_with_one_commit(origin_dir.path());
1020        let canonical = origin_dir.path().to_str().unwrap();
1021
1022        let recloned = reclone(canonical).expect("reclone with no prior slot");
1023        assert_eq!(head_sha(&recloned), head_sha(origin_dir.path()));
1024
1025        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1026    }
1027
1028    /// Remediation (issue #765) — see
1029    /// crates/khive-pack-git/docs/api/cache.md#test-module-notes.
1030    #[test]
1031    fn ensure_clone_refuses_a_markerless_git_directory_at_the_cache_key_path() {
1032        let _guard = ENV_MUTEX.blocking_lock();
1033        let scratch = tempfile::tempdir().expect("tempdir");
1034        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1035
1036        let canonical = "https://example.invalid/lookalike/repo";
1037        let key = cache_key(canonical);
1038        let lookalike = scratch.path().join(&key);
1039        std::fs::create_dir_all(&lookalike).unwrap();
1040        init_origin_with_one_commit(&lookalike);
1041        std::fs::write(lookalike.join("sentinel.txt"), b"do not delete me").unwrap();
1042        let sentinel_sha = head_sha(&lookalike);
1043
1044        let err = ensure_clone(canonical).expect_err("markerless lookalike must be refused");
1045        assert!(
1046            matches!(err, CacheError::UnsafeToReplace(_)),
1047            "expected UnsafeToReplace, got {err:?}"
1048        );
1049
1050        assert!(
1051            lookalike.join("sentinel.txt").exists(),
1052            "sentinel operator data must survive a refused ensure_clone"
1053        );
1054        assert_eq!(
1055            head_sha(&lookalike),
1056            sentinel_sha,
1057            "the lookalike repository's own history must be untouched (no fetch ran)"
1058        );
1059        assert!(
1060            !lookalike.join(MARKER_FILE).exists(),
1061            "a refused ensure_clone must never write the ownership marker either"
1062        );
1063
1064        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1065    }
1066
1067    /// Same guard, symlink variant.
1068    #[cfg(unix)]
1069    #[test]
1070    fn ensure_clone_refuses_a_symlink_at_the_cache_key_path() {
1071        let _guard = ENV_MUTEX.blocking_lock();
1072        let scratch = tempfile::tempdir().expect("tempdir");
1073        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1074
1075        let canonical = "https://example.invalid/symlink-lookalike/repo";
1076        let key = cache_key(canonical);
1077        let link_path = scratch.path().join(&key);
1078
1079        let target = tempfile::tempdir().expect("symlink target");
1080        make_owned_entry(target.path(), "9999999999999999", true);
1081        let real_owned = target.path().join("9999999999999999");
1082        std::fs::write(real_owned.join("sentinel.txt"), b"do not delete me").unwrap();
1083
1084        std::os::unix::fs::symlink(&real_owned, &link_path).expect("create symlink");
1085
1086        let err = ensure_clone(canonical).expect_err("symlink lookalike must be refused");
1087        assert!(
1088            matches!(err, CacheError::UnsafeToReplace(_)),
1089            "expected UnsafeToReplace, got {err:?}"
1090        );
1091        assert!(
1092            real_owned.join("sentinel.txt").exists(),
1093            "the symlink target's sentinel data must survive a refused ensure_clone"
1094        );
1095
1096        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1097    }
1098}