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).
3//!
4//! Clones/fetches into `~/.khive/scratch/git-digest/<cache_key>/`, keyed by
5//! canonical URL (`crate::source::cache_key`). An LRU cap evicts the
6//! least-recently-used clone (by a `.khive-last-used` marker file's mtime,
7//! touched on every successful `ensure_clone`) once the cache exceeds
8//! `digest_cache_max_repos` entries or `digest_cache_max_bytes` total size --
9//! eviction is safe because ingest cursors live in the database, not the
10//! clone (ADR-088 Amendment 1 §Remote-URL mode). Eviction only ever removes
11//! entries it can *prove* it owns (`is_owned_entry`: a 16-hex cache-key
12//! directory name containing both a `.git` dir and the `.khive-last-used`
13//! marker) -- a `KHIVE_GIT_DIGEST_SCRATCH_ROOT` override pointed at a broader
14//! or pre-existing directory must never lose unrelated operator data.
15//!
16//! A per-clone size cap (`digest_cache_clone_max_bytes`) rejects a clone/
17//! fetch that grows past its own budget *before* it ever enters the
18//! addressable cache slot: `ensure_clone` clones/fetches into a staging
19//! directory outside the cache root, measures it, and only moves it into
20//! `<root>/<cache_key>/` when it is under the cap. A too-large clone is
21//! deleted from staging and never touches `evict_lru`'s bookkeeping or the
22//! cache slot. This guarantees the cap is enforced before the clone enters
23//! the cache -- it does NOT bound the transient disk usage of the clone/
24//! fetch child process itself while it runs in staging (`git` has no
25//! reliable pre-flight or mid-transfer size check for a partial
26//! `--filter=blob:none` clone); a single oversized `git clone` can still
27//! transiently consume disk in the staging directory before this check
28//! rejects and removes it.
29//!
30//! Config is env-var driven today (`KHIVE_GIT_DIGEST_CACHE_MAX_REPOS`,
31//! `KHIVE_GIT_DIGEST_CACHE_MAX_BYTES`, `KHIVE_GIT_DIGEST_CLONE_MAX_BYTES`,
32//! `KHIVE_GIT_DIGEST_SCRATCH_ROOT`) rather than a `[git]` TOML section --
33//! see the implementation report for why.
34
35use std::path::{Path, PathBuf};
36use std::process::Command;
37use std::time::SystemTime;
38
39use uuid::Uuid;
40
41use crate::source::cache_key;
42
43pub const DEFAULT_MAX_REPOS: usize = 5;
44pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
45pub const DEFAULT_CLONE_MAX_BYTES: u64 = 1024 * 1024 * 1024;
46
47const MARKER_FILE: &str = ".khive-last-used";
48
49#[derive(Debug)]
50pub enum CacheError {
51    Io(std::io::Error),
52    Git(String),
53    CloneTooLarge {
54        bytes: u64,
55        cap: u64,
56    },
57    /// A repair operation (refetch/reclone) would have to touch a path that
58    /// does not prove itself an owned cache slot (`is_owned_entry`) or is
59    /// not a direct child of the scratch root — refused rather than risking
60    /// deletion of unrelated operator data under an overridden
61    /// `KHIVE_GIT_DIGEST_SCRATCH_ROOT`.
62    UnsafeToReplace(PathBuf),
63}
64
65impl std::fmt::Display for CacheError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            CacheError::Io(e) => write!(f, "scratch-cache I/O error: {e}"),
69            CacheError::Git(msg) => write!(f, "{msg}"),
70            CacheError::CloneTooLarge { bytes, cap } => write!(
71                f,
72                "clone exceeds the per-clone size cap ({bytes} bytes > {cap} bytes); \
73                 the clone was removed. Raise KHIVE_GIT_DIGEST_CLONE_MAX_BYTES if this \
74                 repository's history is legitimately this large."
75            ),
76            CacheError::UnsafeToReplace(path) => write!(
77                f,
78                "refusing to replace {} -- it does not prove itself an owned cache slot",
79                path.display()
80            ),
81        }
82    }
83}
84
85impl std::error::Error for CacheError {}
86
87impl From<std::io::Error> for CacheError {
88    fn from(e: std::io::Error) -> Self {
89        CacheError::Io(e)
90    }
91}
92
93fn scratch_root() -> PathBuf {
94    if let Ok(over) = std::env::var("KHIVE_GIT_DIGEST_SCRATCH_ROOT") {
95        if !over.is_empty() {
96            return PathBuf::from(over);
97        }
98    }
99    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
100    PathBuf::from(home)
101        .join(".khive")
102        .join("scratch")
103        .join("git-digest")
104}
105
106fn env_u64(key: &str, default: u64) -> u64 {
107    std::env::var(key)
108        .ok()
109        .and_then(|v| v.parse().ok())
110        .unwrap_or(default)
111}
112
113fn max_repos() -> usize {
114    std::env::var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS")
115        .ok()
116        .and_then(|v| v.parse().ok())
117        .unwrap_or(DEFAULT_MAX_REPOS)
118}
119
120fn max_total_bytes() -> u64 {
121    env_u64("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", DEFAULT_MAX_TOTAL_BYTES)
122}
123
124fn clone_max_bytes() -> u64 {
125    env_u64("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES", DEFAULT_CLONE_MAX_BYTES)
126}
127
128/// Ensure a local clone of `canonical_url` exists and is up to date; returns
129/// the repo's local path.
130///
131/// An existing path at the cache-key slot is only ever treated as a fetchable
132/// cache slot when it already passes `is_owned_entry` -- a `.git` directory
133/// sitting at that path without the `.khive-last-used` marker (a foreign
134/// directory that happens to collide with the cache key, or a directory a
135/// crashed prior run left in a pre-`touch` state) is refused with
136/// `CacheError::UnsafeToReplace` rather than fetched into or adopted (issue
137/// #765 review round-2 \[High\]-1). A fresh clone is written into a private
138/// staging directory first (`git clone --filter=blob:none`), measured there,
139/// marked with `.khive-last-used` there, and only *moved* into the
140/// addressable `<root>/<cache_key>/` slot once it is under the cap and
141/// already carries its ownership marker -- an oversized clone never enters
142/// the cache slot, never participates in `evict_lru`'s accounting, and is
143/// removed from staging immediately; a process interruption between the
144/// clone and the rename can never leave a live, markerless slot behind.
145///
146/// A repo that grew past the per-clone cap since it was last fetched is
147/// evicted from the cache slot on the spot, through the same ownership-
148/// guarded `remove_owned_entry` every other repair path uses, propagating
149/// any cleanup/ownership failure instead of discarding it.
150///
151/// Runs LRU eviction over the rest of the cache after a successful
152/// clone/fetch (this clone is exempt from its own eviction pass).
153pub fn ensure_clone(canonical_url: &str) -> Result<PathBuf, CacheError> {
154    let root = scratch_root();
155    std::fs::create_dir_all(&root)?;
156    let key = cache_key(canonical_url);
157    let repo_dir = root.join(&key);
158    let cap = clone_max_bytes();
159
160    if repo_dir.join(".git").exists() {
161        if !is_owned_entry(&repo_dir) {
162            return Err(CacheError::UnsafeToReplace(repo_dir));
163        }
164        fetch(&repo_dir)?;
165        // `repo_dir` was just fetched into and its ownership already
166        // confirmed above; it vanishing here is a real problem (e.g. a
167        // racing, non-serialized `ensure_clone`/`reclone` on the same key --
168        // see `refetch_clone`'s doc comment), not a maybe-absent slot, so
169        // propagate rather than swallow.
170        let size = dir_size(&repo_dir)?;
171        if size > cap {
172            remove_owned_entry(&root, &repo_dir)?;
173            return Err(CacheError::CloneTooLarge { bytes: size, cap });
174        }
175        touch(&repo_dir)?;
176    } else {
177        install_fresh_clone(canonical_url, &root, &repo_dir, cap)?;
178    }
179
180    evict_lru(&root, &repo_dir)?;
181    Ok(repo_dir)
182}
183
184/// Re-fetch a corrupt-but-present cache slot with `git fetch --refetch`
185/// (issue #765): downloads a complete fresh filtered packfile rather than
186/// trusting the existing (possibly promisor-incomplete) object store,
187/// repairing a partial/pruned clone in place. Only ever operates on an
188/// existing slot -- callers repair a slot only after a prior `ensure_clone`
189/// already produced one. Re-checks `is_owned_entry` immediately before
190/// fetching (review round-3 \[High\]-1, issue #765 follow-up PR #788): the
191/// gap between `ensure_clone`'s own ownership check and this repair running
192/// -- project resolution and GitHub ingestion happen in between -- is wide
193/// enough for the slot to go markerless or be replaced, so this function
194/// cannot rely on the caller having checked recently.
195pub(crate) fn refetch_clone(canonical_url: &str) -> Result<PathBuf, CacheError> {
196    let root = scratch_root();
197    let key = cache_key(canonical_url);
198    let repo_dir = root.join(&key);
199    if !repo_dir.join(".git").exists() {
200        return Err(CacheError::Git(format!(
201            "refetch requested for {canonical_url:?} but no cache slot exists at {}",
202            repo_dir.display()
203        )));
204    }
205    // Re-check ownership immediately before mutating the slot (review
206    // round-3 \[High\]-1, issue #765 follow-up PR #788): the caller's own
207    // ownership check (`ensure_clone`, much earlier in `handle_digest`)
208    // happens before project resolution and potentially lengthy GitHub
209    // ingestion, so the slot can go markerless -- or be replaced by a
210    // foreign directory colliding with the cache key -- in that interval.
211    // Without this re-check, `fetch_refetch` below would mutate whatever
212    // sits at `repo_dir` and `touch` would mark it owned, making it eligible
213    // for later deletion. There is no same-key serialization for cache
214    // mutation in this crate today (a concurrent `ensure_clone`/`reclone`
215    // racing this same slot is not otherwise excluded) -- this re-check
216    // narrows the adoption bug but does not close a true concurrent-writer
217    // race.
218    if !is_owned_entry(&repo_dir) {
219        return Err(CacheError::UnsafeToReplace(repo_dir));
220    }
221
222    fetch_refetch(&repo_dir)?;
223
224    let cap = clone_max_bytes();
225    // Same reasoning as `ensure_clone`: `repo_dir`'s ownership was just
226    // re-checked and it was just fetched into, so a vanish here is a real
227    // problem to surface, not a maybe-absent slot to size as `0`.
228    let size = dir_size(&repo_dir)?;
229    if size > cap {
230        // Route through the same ownership-guarded removal `reclone` uses
231        // (issue #765 remediation, review round-1 \[High\]-1) rather than a raw
232        // `remove_dir_all` -- a repair primitive must never delete a path
233        // that doesn't prove itself an owned cache slot, even on the cap-
234        // exceeded cleanup path. Propagate a cleanup/ownership failure
235        // instead of discarding it (review round-2 \[High\]-1): a refused or
236        // failed removal must surface as its own error, not be silently
237        // swallowed behind `CloneTooLarge`.
238        remove_owned_entry(&root, &repo_dir)?;
239        return Err(CacheError::CloneTooLarge { bytes: size, cap });
240    }
241
242    touch(&repo_dir)?;
243    evict_lru(&root, &repo_dir)?;
244    Ok(repo_dir)
245}
246
247/// Evict an owned cache slot (if present) and install a fresh clone in its
248/// place (issue #765's fallback when a refetch cannot repair the slot).
249/// Refuses via `CacheError::UnsafeToReplace` when the existing path does not
250/// prove itself an owned cache slot -- the same ownership guard `evict_lru`
251/// uses, so a `KHIVE_GIT_DIGEST_SCRATCH_ROOT` override pointed at a broader
252/// or pre-existing directory can never lose unrelated operator data here
253/// either.
254pub(crate) fn reclone(canonical_url: &str) -> Result<PathBuf, CacheError> {
255    let root = scratch_root();
256    std::fs::create_dir_all(&root)?;
257    let key = cache_key(canonical_url);
258    let repo_dir = root.join(&key);
259    let cap = clone_max_bytes();
260
261    remove_owned_entry(&root, &repo_dir)?;
262    install_fresh_clone(canonical_url, &root, &repo_dir, cap)?;
263
264    evict_lru(&root, &repo_dir)?;
265    Ok(repo_dir)
266}
267
268/// Shared staging-clone-then-move path for both a first-time `ensure_clone`
269/// and a `reclone` repair: clones into a private staging directory outside
270/// the cache root, measures it against the per-clone cap, writes the
271/// `.khive-last-used` ownership marker into the staging directory itself,
272/// and only then moves it into the addressable `<root>/<cache_key>/` slot --
273/// an oversized clone never enters the cache slot, and because the marker is
274/// written before the atomic rename, a process interruption between clone
275/// and rename can never leave a live, markerless slot at the cache-key path
276/// (issue #765 review round-2 \[High\]-1).
277fn install_fresh_clone(
278    canonical_url: &str,
279    root: &Path,
280    repo_dir: &Path,
281    cap: u64,
282) -> Result<(), CacheError> {
283    let staging_dir = root.join(format!(".staging-{}", Uuid::new_v4()));
284    clone(canonical_url, &staging_dir).inspect_err(|_| {
285        // `git clone` can create and partially populate the destination
286        // before failing (network drop, auth failure, bad ref) -- clean
287        // it up so a run of failures doesn't leave `.staging-*` litter
288        // under the scratch root. `evict_lru` deliberately never touches
289        // non-owned names (`is_owned_entry`), so nothing else would ever
290        // reclaim this on its own.
291        let _ = std::fs::remove_dir_all(&staging_dir);
292    })?;
293    let size = dir_size(&staging_dir).inspect_err(|_| {
294        let _ = std::fs::remove_dir_all(&staging_dir);
295    })?;
296    if size > cap {
297        let _ = std::fs::remove_dir_all(&staging_dir);
298        return Err(CacheError::CloneTooLarge { bytes: size, cap });
299    }
300    touch(&staging_dir).inspect_err(|_| {
301        let _ = std::fs::remove_dir_all(&staging_dir);
302    })?;
303    std::fs::rename(&staging_dir, repo_dir).map_err(|e| {
304        let _ = std::fs::remove_dir_all(&staging_dir);
305        CacheError::Io(e)
306    })?;
307    Ok(())
308}
309
310/// Remove `repo_dir` only when it is a direct child of `root` AND passes
311/// `is_owned_entry` -- refuses (`CacheError::UnsafeToReplace`) rather than
312/// deleting anything else, including a not-yet-existing or foreign-shaped
313/// path. A slot that does not currently exist is not an error: there is
314/// simply nothing to remove before installing a fresh clone.
315fn remove_owned_entry(root: &Path, repo_dir: &Path) -> Result<(), CacheError> {
316    if !repo_dir.exists() {
317        return Ok(());
318    }
319    if repo_dir.parent() != Some(root) || !is_owned_entry(repo_dir) {
320        return Err(CacheError::UnsafeToReplace(repo_dir.to_path_buf()));
321    }
322    remove_dir_all_retrying(repo_dir).map_err(CacheError::Io)?;
323    Ok(())
324}
325
326/// `std::fs::remove_dir_all` on a large git working tree can transiently
327/// fail with "directory not empty" when something else briefly touches the
328/// tree mid-removal (e.g. a filesystem indexer) -- retry a few times before
329/// giving up, rather than letting a one-off transient race abort a repair
330/// that would otherwise succeed.
331fn remove_dir_all_retrying(path: &Path) -> std::io::Result<()> {
332    let mut last_err = None;
333    for attempt in 0..5 {
334        match std::fs::remove_dir_all(path) {
335            Ok(()) => return Ok(()),
336            Err(e) => {
337                last_err = Some(e);
338                if attempt < 4 {
339                    std::thread::sleep(std::time::Duration::from_millis(20));
340                }
341            }
342        }
343    }
344    Err(last_err.expect("loop always sets last_err before exiting"))
345}
346
347/// `-c maintenance.auto=false` on every clone/fetch into a cache slot, as
348/// defensive hardening. `git fetch` runs auto-maintenance after it
349/// finishes when `maintenance.auto` (default true) is set, and since git
350/// 2.47 that maintenance runs as a *detached background child*
351/// (`git maintenance run --auto --detach`) that can outlive the foreground
352/// command; on 2.46 and earlier it ran synchronously. The spawn is
353/// trace2-proven in both directions on the `fetch --refetch` path
354/// (`GIT_TRACE2_EVENT`, git 2.49: with default config the child forks; with
355/// `maintenance.auto=false` it does not). The same trace showed `clone`
356/// spawning no maintenance child; the flag is applied to the clone builder
357/// too purely as harmless defensive configuration, with no trace evidence
358/// claimed for that path. When one of the detached child's tasks fires it
359/// mutates the slot's `.git` tree (commit-graph writes, pack maintenance,
360/// lock files) concurrently with any `dir_size`/`evict_lru` walk of the
361/// same slot. Whether such a task actually fired in issue #842's historical
362/// macOS ENOENT failures is not proven -- in small repos the child
363/// typically finds no task to run and exits quickly -- so the load-bearing
364/// fix for that flake family is the descendant-vanish tolerance in
365/// `dir_size`; this flag removes the one background mutator git itself can
366/// fork into our cache slots. `gc.auto=0` alone does **not** suppress the
367/// child (trace2-verified); it is kept alongside because it disables
368/// `git gc --auto`'s separate opportunistic-gc check, harmless to also turn
369/// off here.
370///
371/// This does not mean a cache slot is naturally garbage-collected some other
372/// way instead: no cache-slot repo is ever gc'd or maintenance'd by us.
373/// Growth is bounded by wholesale eviction, not in-place compaction --
374/// `ensure_clone`/`refetch_clone` delete a slot outright
375/// (`remove_owned_entry`) the moment it measures over
376/// `digest_cache_clone_max_bytes` after a fetch, and `evict_lru` deletes
377/// whole least-recently-used slot directories once the cache-wide
378/// `digest_cache_max_repos`/`digest_cache_max_bytes` caps are exceeded. A
379/// slot can be fetched into repeatedly, but it can never accumulate objects
380/// past its own size cap without being deleted and re-cloned fresh, so there
381/// is nothing for git's own gc/maintenance to usefully do in a cache slot.
382fn clone(url: &str, dest: &Path) -> Result<(), CacheError> {
383    let status = Command::new("git")
384        .arg("-c")
385        .arg("core.hooksPath=/dev/null")
386        .arg("-c")
387        .arg("gc.auto=0")
388        .arg("-c")
389        .arg("maintenance.auto=false")
390        .arg("clone")
391        .arg("--filter=blob:none")
392        .arg(url)
393        .arg(dest)
394        .env("GIT_TERMINAL_PROMPT", "0")
395        .status()
396        .map_err(|e| CacheError::Git(format!("spawning git clone: {e}")))?;
397    if !status.success() {
398        return Err(CacheError::Git(format!(
399            "git clone {url} failed (exit {status})"
400        )));
401    }
402    Ok(())
403}
404
405fn fetch(repo: &Path) -> Result<(), CacheError> {
406    let status = Command::new("git")
407        .arg("-c")
408        .arg("core.hooksPath=/dev/null")
409        .arg("-c")
410        .arg("gc.auto=0")
411        .arg("-c")
412        .arg("maintenance.auto=false")
413        .arg("-C")
414        .arg(repo)
415        .arg("fetch")
416        .arg("--prune")
417        .env("GIT_TERMINAL_PROMPT", "0")
418        .status()
419        .map_err(|e| CacheError::Git(format!("spawning git fetch: {e}")))?;
420    if !status.success() {
421        return Err(CacheError::Git(format!(
422            "git fetch in {} failed (exit {status})",
423            repo.display()
424        )));
425    }
426    Ok(())
427}
428
429/// Issue #765 repair primitive: `git fetch --refetch origin` obtains a
430/// complete fresh filtered packfile instead of incrementally trusting the
431/// existing (possibly promisor-incomplete) object store -- the documented
432/// fix for a partial clone that has dropped objects it should still have.
433fn fetch_refetch(repo: &Path) -> Result<(), CacheError> {
434    let status = Command::new("git")
435        .arg("-c")
436        .arg("core.hooksPath=/dev/null")
437        .arg("-c")
438        .arg("gc.auto=0")
439        .arg("-c")
440        .arg("maintenance.auto=false")
441        .arg("-C")
442        .arg(repo)
443        .arg("fetch")
444        .arg("--refetch")
445        .arg("origin")
446        .env("GIT_TERMINAL_PROMPT", "0")
447        .status()
448        .map_err(|e| CacheError::Git(format!("spawning git fetch --refetch: {e}")))?;
449    if !status.success() {
450        return Err(CacheError::Git(format!(
451            "git fetch --refetch in {} failed (exit {status})",
452            repo.display()
453        )));
454    }
455    Ok(())
456}
457
458/// Wrap an I/O error with the operation and path it happened on -- a bare
459/// `CacheError::Io(e)` at these call sites used to surface as an opaque
460/// "No such file or directory" with no way to tell which of the many paths
461/// `dir_size`/`touch`/`evict_lru` touch actually disappeared.
462fn io_err(op: &str, path: &Path, e: std::io::Error) -> CacheError {
463    CacheError::Io(std::io::Error::new(
464        e.kind(),
465        format!("{op} {}: {e}", path.display()),
466    ))
467}
468
469fn touch(repo_dir: &Path) -> Result<(), CacheError> {
470    let marker = repo_dir.join(MARKER_FILE);
471    std::fs::write(&marker, b"").map_err(|e| io_err("touch: write marker", &marker, e))?;
472    Ok(())
473}
474
475/// Recursive directory size, following no symlinks (`symlink_metadata`
476/// throughout, so a symlink itself is sized but never traversed -- clones
477/// never legitimately contain symlinked directories pointing outside the
478/// clone, and this avoids any possibility of a symlink loop).
479///
480/// Tolerant of a *descendant* disappearing mid-walk (a vanished entry
481/// beneath an existing root contributes 0 bytes rather than aborting the
482/// whole size computation): a cache slot's `.git` tree can legitimately be
483/// mutated by something outside this function's control while it walks it
484/// -- a concurrent `evict_lru`/`ensure_clone` repair on the same slot, or a
485/// background `git maintenance` child from before `maintenance.auto=false`
486/// applied to every command this crate issues. This accounting is
487/// inherently a snapshot of a possibly-changing tree (ADR-088 Amendment 1:
488/// eviction is safe because ingest cursors live in the database, not the
489/// clone), so "a thing under the root I was about to size is already gone"
490/// is not an error here.
491///
492/// The walk **root** itself vanishing is different and is NOT tolerated --
493/// it surfaces as `CacheError::Io(NotFound)` rather than silently sizing to
494/// `0`. A caller that genuinely expects the root it's sizing to sometimes be
495/// absent (rather than an existing root racing a mid-walk mutation) must
496/// check for that error explicitly and decide its own semantics at that call
497/// site (`evict_lru` does this for a listed entry that a concurrent repair
498/// deleted between `read_dir` and this call -- see there); `dir_size` itself
499/// never launders a missing root into a bare `0`, which previously let
500/// `evict_lru` report success with a missing keep slot or count a phantom
501/// candidate and evict a valid one unnecessarily.
502fn dir_size(path: &Path) -> Result<u64, CacheError> {
503    let mut total = 0u64;
504    let mut stack = vec![path.to_path_buf()];
505    while let Some(p) = stack.pop() {
506        let is_root = p == path;
507        let md = match std::fs::symlink_metadata(&p) {
508            Ok(md) => md,
509            Err(e) if e.kind() == std::io::ErrorKind::NotFound && !is_root => continue,
510            Err(e) => return Err(io_err("dir_size: stat", &p, e)),
511        };
512        if md.is_dir() {
513            let read_dir = match std::fs::read_dir(&p) {
514                Ok(read_dir) => read_dir,
515                Err(e) if e.kind() == std::io::ErrorKind::NotFound && !is_root => continue,
516                Err(e) => return Err(io_err("dir_size: read_dir", &p, e)),
517            };
518            for entry in read_dir {
519                match entry {
520                    Ok(entry) => stack.push(entry.path()),
521                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
522                    Err(e) => return Err(io_err("dir_size: read_dir entry", &p, e)),
523                }
524            }
525        } else {
526            total += md.len();
527        }
528    }
529    Ok(total)
530}
531
532/// Whether `path` is a directory `ensure_clone` could plausibly have
533/// created: a 16-lowercase-hex `cache_key`-shaped directory name (never a
534/// UUID staging dir, never an arbitrary operator directory), itself a real
535/// directory rather than a symlink (a symlink placed at the cache-key path
536/// pointing at an unrelated owned-looking or foreign directory must never be
537/// treated as an owned slot), containing both a `.git` entry and the
538/// `.khive-last-used` marker written by `touch`. Eviction (and any future
539/// scratch-root cleanup) must only ever remove entries that pass this check
540/// -- a `KHIVE_GIT_DIGEST_SCRATCH_ROOT` override pointed at a broader or
541/// pre-existing directory must never lose unrelated data sitting next to the
542/// cache slots.
543fn is_owned_entry(path: &Path) -> bool {
544    let name = match path.file_name().and_then(|n| n.to_str()) {
545        Some(n) => n,
546        None => return false,
547    };
548    if name.len() != 16
549        || !name
550            .chars()
551            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
552    {
553        return false;
554    }
555    match std::fs::symlink_metadata(path) {
556        Ok(md) if md.is_dir() => {}
557        _ => return false,
558    }
559    path.join(".git").exists() && path.join(MARKER_FILE).exists()
560}
561
562/// Evict least-recently-used clones under `root` (by `.khive-last-used`
563/// mtime) until both the repo-count cap and the total-byte cap are
564/// satisfied. `keep` (the clone `ensure_clone` just touched) is never
565/// evicted. Only removes paths that are direct children of `root` AND pass
566/// `is_owned_entry` -- eviction never touches user-owned or non-cache paths.
567///
568/// `keep`'s own `dir_size` (below) is deliberately NOT tolerant of `keep`
569/// vanishing: every caller touches (or freshly installs) `keep` immediately
570/// before calling `evict_lru` in the same synchronous call chain, so `keep`
571/// disappearing out from under this call is not an expected repair race --
572/// it is either a genuine bug or an external actor deleting our slot, and
573/// silently sizing it to `0` would let eviction report success while the
574/// slot the caller asked to keep is actually gone. A listed *candidate*
575/// entry (below) is different -- another `evict_lru`/`ensure_clone`
576/// repairing the same root can legitimately delete it between the
577/// `read_dir` listing above and the `dir_size` call below, so that vanish is
578/// tolerated by skipping the entry rather than aborting the whole pass.
579fn evict_lru(root: &Path, keep: &Path) -> Result<(), CacheError> {
580    let mut entries: Vec<(PathBuf, SystemTime, u64)> = Vec::new();
581    let read_dir =
582        std::fs::read_dir(root).map_err(|e| io_err("evict_lru: read_dir root", root, e))?;
583    for entry in read_dir {
584        let entry = match entry {
585            Ok(entry) => entry,
586            // The directory listing raced a concurrent removal of one of its
587            // own entries (e.g. another `evict_lru`/`ensure_clone` repairing
588            // the same root) -- nothing to evict there anymore.
589            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
590            Err(e) => return Err(io_err("evict_lru: read_dir entry", root, e)),
591        };
592        let p = entry.path();
593        if !p.is_dir() || p == keep || !is_owned_entry(&p) {
594            continue;
595        }
596        let mtime = std::fs::metadata(p.join(MARKER_FILE))
597            .and_then(|m| m.modified())
598            .unwrap_or(SystemTime::UNIX_EPOCH);
599        let size = match dir_size(&p) {
600            Ok(size) => size,
601            // `p` was listed above but a concurrent repair on the same root
602            // has since deleted it whole -- there is no slot left to weigh
603            // in eviction accounting, not a size of `0` to record.
604            Err(CacheError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => continue,
605            Err(e) => return Err(e),
606        };
607        entries.push((p, mtime, size));
608    }
609    entries.sort_by_key(|(_, mtime, _)| *mtime);
610
611    let keep_size = dir_size(keep)?;
612    let mut total: u64 = entries.iter().map(|(_, _, s)| s).sum::<u64>() + keep_size;
613    let mut count = entries.len() + 1;
614    let cap_repos = max_repos();
615    let cap_bytes = max_total_bytes();
616
617    for (path, _, size) in entries {
618        if count <= cap_repos && total <= cap_bytes {
619            break;
620        }
621        let _ = std::fs::remove_dir_all(&path);
622        count -= 1;
623        total = total.saturating_sub(size);
624    }
625    Ok(())
626}
627
628/// `scratch_root()` reads process-global env vars; serialize any in-crate
629/// test (in this module or elsewhere, e.g. `recovery_tests.rs`) that touches
630/// it, so the whole `cargo test` binary's parallel test threads never race
631/// on `KHIVE_GIT_DIGEST_SCRATCH_ROOT`/cache-cap env vars/`PATH`. A
632/// `tokio::sync::Mutex` rather than `std::sync::Mutex` so async tests can
633/// hold the guard across `.await` points (`blocking_lock()` for this
634/// module's plain sync `#[test]`s).
635#[cfg(test)]
636pub(crate) static ENV_MUTEX: std::sync::LazyLock<tokio::sync::Mutex<()>> =
637    std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    /// Build a directory shaped exactly like a real `ensure_clone` cache
644    /// slot: a 16-lowercase-hex name (a real `cache_key` output) containing
645    /// a `.git` dir and (optionally) the `.khive-last-used` marker.
646    fn make_owned_entry(root: &Path, key: &str, with_marker: bool) -> PathBuf {
647        assert_eq!(key.len(), 16, "test cache keys must be 16 hex chars");
648        let p = root.join(key);
649        std::fs::create_dir_all(p.join(".git")).unwrap();
650        if with_marker {
651            std::fs::write(p.join(MARKER_FILE), b"").unwrap();
652        }
653        p
654    }
655
656    /// ADR-088 Amendment 1 fix-round r2 Medium-1: a `git clone` failure (bad
657    /// source, no network needed -- a nonexistent local path fails
658    /// immediately) must not leave a `.staging-<uuid>` directory behind.
659    /// `evict_lru` deliberately never touches non-owned names, so a leaked
660    /// staging dir would otherwise accumulate forever across repeated
661    /// failures.
662    #[test]
663    fn ensure_clone_cleans_up_staging_dir_on_clone_failure() {
664        let _guard = ENV_MUTEX.blocking_lock();
665        let dir = tempfile::tempdir().expect("tempdir");
666        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", dir.path());
667
668        let bogus_source = dir.path().join("does-not-exist-as-a-repo");
669        let result = ensure_clone(bogus_source.to_str().expect("utf8 path"));
670        assert!(
671            result.is_err(),
672            "cloning a nonexistent local path must fail: {result:?}"
673        );
674
675        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
676            .expect("read scratch root")
677            .filter_map(|e| e.ok())
678            .map(|e| e.file_name().to_string_lossy().into_owned())
679            .filter(|name| name.starts_with(".staging-"))
680            .collect();
681        assert!(
682            leftovers.is_empty(),
683            "a failed clone must not leave .staging-* directories behind: {leftovers:?}"
684        );
685
686        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
687    }
688
689    #[test]
690    fn evict_lru_removes_oldest_past_repo_cap() {
691        let _guard = ENV_MUTEX.blocking_lock();
692        let dir = tempfile::tempdir().expect("tempdir");
693        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", dir.path());
694        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "1");
695        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
696
697        let root = dir.path();
698        let old = make_owned_entry(root, "1111111111111111", true);
699        // Ensure a real mtime gap.
700        std::thread::sleep(std::time::Duration::from_millis(20));
701        let new = make_owned_entry(root, "2222222222222222", true);
702
703        evict_lru(root, &new).expect("evict");
704
705        assert!(!old.exists(), "the older clone must be evicted");
706        assert!(new.exists(), "the kept clone must survive");
707
708        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
709        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
710        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
711    }
712
713    #[test]
714    fn evict_lru_only_touches_children_of_root() {
715        let _guard = ENV_MUTEX.blocking_lock();
716        let dir = tempfile::tempdir().expect("tempdir");
717        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "5");
718        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
719
720        let root = dir.path().join("scratch-root");
721        std::fs::create_dir_all(&root).unwrap();
722        let kept = make_owned_entry(&root, "3333333333333333", true);
723
724        evict_lru(&root, &kept).expect("evict");
725        assert!(kept.exists());
726
727        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
728        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
729    }
730
731    #[test]
732    fn evict_lru_never_removes_a_foreign_directory_under_root() {
733        let _guard = ENV_MUTEX.blocking_lock();
734        let dir = tempfile::tempdir().expect("tempdir");
735        // Cap of 0 repos: without ownership filtering this would previously
736        // have wiped out every child of root, including operator data.
737        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "0");
738        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "0");
739
740        let root = dir.path().join("scratch-root");
741        std::fs::create_dir_all(&root).unwrap();
742        let foreign = root.join("not-a-cache-entry");
743        std::fs::create_dir_all(&foreign).unwrap();
744        std::fs::write(foreign.join("important.txt"), b"do not delete me").unwrap();
745        let kept = make_owned_entry(&root, "4444444444444444", true);
746
747        evict_lru(&root, &kept).expect("evict");
748
749        assert!(
750            foreign.exists(),
751            "a directory that doesn't look like a cache slot must survive eviction"
752        );
753        assert!(
754            foreign.join("important.txt").exists(),
755            "foreign directory contents must be untouched"
756        );
757
758        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
759        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
760    }
761
762    #[test]
763    fn evict_lru_never_removes_an_owned_looking_dir_missing_the_marker() {
764        let _guard = ENV_MUTEX.blocking_lock();
765        let dir = tempfile::tempdir().expect("tempdir");
766        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "0");
767        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "0");
768
769        let root = dir.path().join("scratch-root");
770        std::fs::create_dir_all(&root).unwrap();
771        // Has a .git dir and a valid cache-key-shaped name, but no marker --
772        // e.g. a clone that failed after `clone()` but before `touch()`.
773        let no_marker = make_owned_entry(&root, "5555555555555555", false);
774        let kept = make_owned_entry(&root, "6666666666666666", true);
775
776        evict_lru(&root, &kept).expect("evict");
777
778        assert!(
779            no_marker.exists(),
780            "an owned-looking directory without the marker must survive eviction"
781        );
782
783        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
784        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
785    }
786
787    #[test]
788    fn is_owned_entry_rejects_non_cache_shapes() {
789        let dir = tempfile::tempdir().expect("tempdir");
790        let root = dir.path();
791
792        // Wrong length.
793        let short = root.join("abc123");
794        std::fs::create_dir_all(short.join(".git")).unwrap();
795        std::fs::write(short.join(MARKER_FILE), b"").unwrap();
796        assert!(!is_owned_entry(&short));
797
798        // Uppercase hex (cache_key is always lowercase).
799        let upper = root.join("ABCDEF0123456789");
800        std::fs::create_dir_all(upper.join(".git")).unwrap();
801        std::fs::write(upper.join(MARKER_FILE), b"").unwrap();
802        assert!(!is_owned_entry(&upper));
803
804        // Right shape but missing .git.
805        let no_git = root.join("7777777777777777");
806        std::fs::create_dir_all(&no_git).unwrap();
807        std::fs::write(no_git.join(MARKER_FILE), b"").unwrap();
808        assert!(!is_owned_entry(&no_git));
809
810        let owned = make_owned_entry(root, "8888888888888888", true);
811        assert!(is_owned_entry(&owned));
812    }
813
814    #[test]
815    fn dir_size_sums_file_bytes_recursively() {
816        let dir = tempfile::tempdir().expect("tempdir");
817        std::fs::write(dir.path().join("a.txt"), b"12345").unwrap();
818        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
819        std::fs::write(dir.path().join("sub/b.txt"), b"1234567890").unwrap();
820        assert_eq!(dir_size(dir.path()).unwrap(), 15);
821    }
822
823    /// PR #847 round-3 Major-2: the walk root vanishing must surface as an
824    /// error, never a laundered `Ok(0)` -- distinct from a descendant
825    /// vanishing beneath a still-existing root (see the Barrier tests
826    /// below). A root that was never there to begin with is the simplest,
827    /// fully deterministic instance of "the root itself is missing".
828    #[test]
829    fn dir_size_errors_when_the_root_itself_is_missing() {
830        let dir = tempfile::tempdir().expect("tempdir");
831        let missing = dir.path().join("does-not-exist");
832        let err = dir_size(&missing).expect_err("a missing root must error, not size to 0");
833        assert!(
834            matches!(&err, CacheError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
835            "expected CacheError::Io(NotFound), got {err:?}"
836        );
837    }
838
839    /// `evict_lru`'s `keep` argument: every caller (`ensure_clone`,
840    /// `refetch_clone`, `reclone`) has just touched or freshly installed
841    /// `keep` immediately before calling `evict_lru`, so `keep` vanishing is
842    /// a real problem to surface, not a maybe-absent slot -- `evict_lru`
843    /// must propagate `dir_size(keep)`'s error rather than treat it as an
844    /// empty, evictable-looking slot.
845    #[test]
846    fn evict_lru_errors_when_keep_itself_is_missing() {
847        let _guard = ENV_MUTEX.blocking_lock();
848        let dir = tempfile::tempdir().expect("tempdir");
849        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "5");
850        std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
851
852        let root = dir.path().join("scratch-root");
853        std::fs::create_dir_all(&root).unwrap();
854        let missing_keep = root.join("0000000000000000");
855
856        let err = evict_lru(&root, &missing_keep).expect_err("a missing keep root must error");
857        assert!(
858            matches!(&err, CacheError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
859            "expected CacheError::Io(NotFound), got {err:?}"
860        );
861
862        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
863        std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
864    }
865
866    /// Issue #842's macOS ENOENT flake family: `dir_size` walks a tree that
867    /// can legitimately be mutated out from under it (git's own detached
868    /// background maintenance child, or a racing `evict_lru`/`ensure_clone`
869    /// repair on the same
870    /// slot) -- a subdirectory disappearing between `read_dir` listing it and
871    /// this walk descending into it must shrink the total, not abort the
872    /// whole computation with `CacheError::Io(NotFound)`.
873    ///
874    /// This is a genuine cross-thread filesystem race, not a fully
875    /// deterministic single-shot repro: a `std::sync::Barrier` releases both
876    /// threads at the same instant, a wide fan of sibling subdirectories
877    /// gives the walk many entries to still be processing when the deleter
878    /// runs, and the whole race is repeated many times so the window is
879    /// almost certain to be hit at least once across the loop. Pre-fix (see
880    /// the sabotage note on `dir_size` above), this reliably reproduces
881    /// `CacheError::Io` within a handful of iterations on this machine; it is
882    /// not a `sleep`-based synchronization, so it is not always the exact
883    /// same interleaving twice, but the failure is real and observable, not
884    /// theoretical.
885    #[test]
886    fn dir_size_tolerates_a_subdirectory_removed_mid_walk() {
887        for _ in 0..200 {
888            let dir = tempfile::tempdir().expect("tempdir");
889            let root = dir.path().to_path_buf();
890            let victim = root.join("victim");
891            std::fs::create_dir_all(&victim).unwrap();
892            for i in 0..64 {
893                std::fs::write(victim.join(format!("f{i}.txt")), b"0123456789").unwrap();
894            }
895            // A wide fan of siblings so the walk still has entries left on
896            // its stack (and is plausibly still inside `victim`) at the
897            // instant the other thread deletes it.
898            for i in 0..64 {
899                let sibling = root.join(format!("sibling{i}"));
900                std::fs::create_dir_all(&sibling).unwrap();
901                std::fs::write(sibling.join("s.txt"), b"0123456789").unwrap();
902            }
903
904            let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
905            let walk_root = root.clone();
906            let walk_barrier = barrier.clone();
907            let walker = std::thread::spawn(move || {
908                walk_barrier.wait();
909                dir_size(&walk_root)
910            });
911            let delete_victim = victim.clone();
912            let deleter = std::thread::spawn(move || {
913                barrier.wait();
914                let _ = std::fs::remove_dir_all(&delete_victim);
915            });
916
917            let result = walker.join().expect("walker thread");
918            deleter.join().expect("deleter thread");
919
920            assert!(
921                result.is_ok(),
922                "dir_size must tolerate a subdirectory vanishing mid-walk, got {result:?}"
923            );
924        }
925    }
926
927    /// Companion to the test above, pinning the other half of the Major-2
928    /// contract (PR #847 round-3): when the vanishing path is the walk
929    /// **root** itself -- not a descendant beneath a still-existing root --
930    /// `dir_size` must surface an error rather than tolerate it. Same
931    /// barrier-race harness, but `root` is left empty (an empty-directory
932    /// removal is a single `rmdir` syscall, the same order of cost as the
933    /// `symlink_metadata`/`read_dir` calls `dir_size` opens with -- a
934    /// populated root, by contrast, has its own directory entry removed
935    /// *last* by `remove_dir_all` after every child, well after the
936    /// walker's first two syscalls would already have completed, which
937    /// would make the root-vanish race effectively unreachable). Pre-fix,
938    /// `dir_size` treated a disappearing root exactly like a disappearing
939    /// descendant and returned `Ok(0)`, which could let `evict_lru` report
940    /// success over a missing `keep` slot or count a phantom `0`-byte
941    /// candidate.
942    #[test]
943    fn dir_size_errors_when_the_root_is_removed_mid_walk() {
944        let mut saw_error = false;
945        for _ in 0..500 {
946            let dir = tempfile::tempdir().expect("tempdir");
947            let root = dir.path().join("slot");
948            std::fs::create_dir_all(&root).unwrap();
949
950            let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
951            let walk_root = root.clone();
952            let walk_barrier = barrier.clone();
953            let walker = std::thread::spawn(move || {
954                walk_barrier.wait();
955                dir_size(&walk_root)
956            });
957            let delete_root = root.clone();
958            let deleter = std::thread::spawn(move || {
959                barrier.wait();
960                let _ = std::fs::remove_dir(&delete_root);
961            });
962
963            let result = walker.join().expect("walker thread");
964            deleter.join().expect("deleter thread");
965
966            match result {
967                Ok(_) => continue, // walker won the race this round; try again
968                Err(CacheError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
969                    saw_error = true;
970                }
971                Err(e) => panic!("unexpected error kind from a vanished root: {e:?}"),
972            }
973        }
974        assert!(
975            saw_error,
976            "root-vanish-mid-walk race was never hit across 500 iterations; \
977             widen the fixture or investigate the barrier timing"
978        );
979    }
980
981    // ── issue #765: refetch/reclone repair primitives ──────────────────────
982
983    fn git(repo: &Path, args: &[&str]) {
984        let out = Command::new("git")
985            .arg("-C")
986            .arg(repo)
987            .args(args)
988            .output()
989            .expect("spawn git");
990        assert!(
991            out.status.success(),
992            "git {args:?} failed: {}",
993            String::from_utf8_lossy(&out.stderr)
994        );
995    }
996
997    /// A real local repo usable as an `ensure_clone`/`refetch_clone`/`reclone`
998    /// `canonical_url` (git accepts a plain filesystem path as a clone/fetch
999    /// source, same as the existing `ensure_clone_cleans_up_staging_dir_*`
1000    /// test does for a failure case).
1001    fn init_origin_with_one_commit(repo: &Path) {
1002        git(repo, &["init", "-q", "-b", "main"]);
1003        git(repo, &["config", "user.email", "test@example.com"]);
1004        git(repo, &["config", "user.name", "Test User"]);
1005        std::fs::write(repo.join("a.txt"), b"hello").unwrap();
1006        git(repo, &["add", "a.txt"]);
1007        git(repo, &["commit", "-q", "-m", "initial"]);
1008    }
1009
1010    fn add_commit(repo: &Path, rel: &str, contents: &str, message: &str) {
1011        std::fs::write(repo.join(rel), contents).unwrap();
1012        git(repo, &["add", rel]);
1013        git(repo, &["commit", "-q", "-m", message]);
1014    }
1015
1016    fn head_sha(repo: &Path) -> String {
1017        let out = Command::new("git")
1018            .arg("-C")
1019            .arg(repo)
1020            .args(["rev-parse", "HEAD"])
1021            .output()
1022            .expect("rev-parse");
1023        String::from_utf8_lossy(&out.stdout).trim().to_string()
1024    }
1025
1026    /// The primary #765 acceptance path: a slot already exists (via
1027    /// `ensure_clone`); `refetch_clone` must pull history the slot doesn't
1028    /// have yet (standing in for genuinely corrupt/incomplete objects, which
1029    /// `git fetch --refetch` repairs the same way -- by re-obtaining a
1030    /// complete fresh packfile from the remote).
1031    #[test]
1032    fn refetch_clone_updates_an_existing_slot_to_the_remote_tip() {
1033        let _guard = ENV_MUTEX.blocking_lock();
1034        let scratch = tempfile::tempdir().expect("tempdir");
1035        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1036
1037        let origin_dir = tempfile::tempdir().expect("tempdir");
1038        init_origin_with_one_commit(origin_dir.path());
1039        let canonical = origin_dir.path().to_str().unwrap();
1040
1041        let first = ensure_clone(canonical).expect("initial ensure_clone");
1042        let before = head_sha(&first);
1043
1044        add_commit(origin_dir.path(), "b.txt", "world", "second");
1045        let expected_tip = head_sha(origin_dir.path());
1046        assert_ne!(before, expected_tip, "origin must have moved on");
1047
1048        let repaired = refetch_clone(canonical).expect("refetch_clone");
1049        assert_eq!(repaired, first, "refetch repairs the same cache slot path");
1050        git(&repaired, &["show", &format!("{expected_tip}:b.txt")]);
1051
1052        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1053    }
1054
1055    /// Review round-1 \[High\]-1 remediation (issue #765), tightened by review
1056    /// round-2 \[High\]-1: `refetch_clone`'s over-cap cleanup must go through
1057    /// the same ownership guard `reclone` uses, not a raw `remove_dir_all`,
1058    /// AND must propagate that guard's failure rather than discarding it --
1059    /// a slot that has lost its `.khive-last-used` marker (simulating a
1060    /// directory the guard cannot prove it owns) survives over-cap cleanup,
1061    /// and the caller sees the ownership failure (`UnsafeToReplace`) that
1062    /// actually occurred, not a laundered `CloneTooLarge`. Since review
1063    /// round-3 \[High\]-1 added a pre-fetch ownership re-check, this markerless
1064    /// slot is now refused before `fetch_refetch` even runs (see
1065    /// `refetch_clone_refuses_a_markerless_slot_under_the_cap` below) rather
1066    /// than at the over-cap cleanup step this test originally targeted --
1067    /// the assertions still hold (`UnsafeToReplace`, slot survives), so this
1068    /// remains a valid regression guard for the cleanup path once a slot
1069    /// somehow reaches it un-owned.
1070    #[test]
1071    fn refetch_clone_over_cap_cleanup_never_deletes_an_unproven_slot() {
1072        let _guard = ENV_MUTEX.blocking_lock();
1073        let scratch = tempfile::tempdir().expect("tempdir");
1074        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1075
1076        let origin_dir = tempfile::tempdir().expect("tempdir");
1077        init_origin_with_one_commit(origin_dir.path());
1078        let canonical = origin_dir.path().to_str().unwrap();
1079
1080        let slot = ensure_clone(canonical).expect("initial ensure_clone");
1081        // Simulate a slot the ownership guard cannot prove it owns (e.g. a
1082        // crash between a prior clone/fetch and `touch`, or a foreign
1083        // directory occupying this exact cache-key path) by removing the
1084        // marker `touch` would normally have written.
1085        std::fs::remove_file(slot.join(".khive-last-used")).expect("remove marker");
1086
1087        std::env::set_var("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES", "1");
1088        let err = refetch_clone(canonical).expect_err("refetch must report the ownership error");
1089        assert!(
1090            matches!(err, CacheError::UnsafeToReplace(_)),
1091            "expected UnsafeToReplace (the cleanup's ownership failure, propagated), got {err:?}"
1092        );
1093        assert!(
1094            slot.exists(),
1095            "a slot the ownership guard cannot prove it owns must survive over-cap cleanup"
1096        );
1097
1098        std::env::remove_var("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES");
1099        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1100    }
1101
1102    /// Review round-3 \[High\]-1 remediation (issue #765 follow-up PR #788):
1103    /// `refetch_clone` must refuse a markerless slot *before* ever calling
1104    /// `fetch_refetch`, not only on the over-cap cleanup branch the previous
1105    /// test exercises. Under the default (non-cap-exceeded) cap, a
1106    /// pre-fetch ownership check is the only thing standing between a
1107    /// markerless slot and a real fetch: the origin is given fresh history
1108    /// so a fetch that ran despite the missing marker would be directly
1109    /// observable via a moved `HEAD`.
1110    #[test]
1111    fn refetch_clone_refuses_a_markerless_slot_under_the_cap() {
1112        let _guard = ENV_MUTEX.blocking_lock();
1113        let scratch = tempfile::tempdir().expect("tempdir");
1114        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1115
1116        let origin_dir = tempfile::tempdir().expect("tempdir");
1117        init_origin_with_one_commit(origin_dir.path());
1118        let canonical = origin_dir.path().to_str().unwrap();
1119
1120        let slot = ensure_clone(canonical).expect("initial ensure_clone");
1121        let sentinel_sha = head_sha(&slot);
1122        std::fs::remove_file(slot.join(MARKER_FILE)).expect("remove marker");
1123
1124        // The origin moves on -- if the ownership guard failed to fire and
1125        // a real fetch ran, the slot's HEAD would follow.
1126        add_commit(origin_dir.path(), "b.txt", "world", "second");
1127
1128        let err = refetch_clone(canonical)
1129            .expect_err("a markerless slot must be refused before any fetch runs");
1130        assert!(
1131            matches!(err, CacheError::UnsafeToReplace(_)),
1132            "expected UnsafeToReplace, got {err:?}"
1133        );
1134        assert_eq!(
1135            head_sha(&slot),
1136            sentinel_sha,
1137            "no fetch must have run against the markerless slot"
1138        );
1139        assert!(
1140            !slot.join(MARKER_FILE).exists(),
1141            "a refused refetch must never (re)write the ownership marker"
1142        );
1143
1144        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1145    }
1146
1147    #[test]
1148    fn refetch_clone_errors_when_no_slot_exists() {
1149        let _guard = ENV_MUTEX.blocking_lock();
1150        let scratch = tempfile::tempdir().expect("tempdir");
1151        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1152
1153        let err = refetch_clone("https://example.invalid/never-cloned/repo")
1154            .expect_err("no slot exists yet");
1155        assert!(
1156            matches!(err, CacheError::Git(_)),
1157            "expected CacheError::Git, got {err:?}"
1158        );
1159
1160        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1161    }
1162
1163    /// #765's fallback path: a refetch that cannot repair the slot (here,
1164    /// simulated by pointing the existing slot's `origin` remote at a
1165    /// nonexistent path so `git fetch --refetch` itself fails) is followed by
1166    /// `reclone`, which ignores the broken clone entirely and clones fresh
1167    /// from the still-good `canonical_url`.
1168    #[test]
1169    fn reclone_replaces_a_slot_whose_refetch_cannot_succeed() {
1170        let _guard = ENV_MUTEX.blocking_lock();
1171        let scratch = tempfile::tempdir().expect("tempdir");
1172        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1173
1174        let origin_dir = tempfile::tempdir().expect("tempdir");
1175        init_origin_with_one_commit(origin_dir.path());
1176        let canonical = origin_dir.path().to_str().unwrap();
1177
1178        let slot = ensure_clone(canonical).expect("initial ensure_clone");
1179        // Break the slot's own remote so `fetch --refetch origin` fails --
1180        // standing in for a corrupt slot that cannot self-repair via refetch.
1181        git(
1182            &slot,
1183            &[
1184                "remote",
1185                "set-url",
1186                "origin",
1187                "/nonexistent/path/does-not-exist",
1188            ],
1189        );
1190        assert!(matches!(refetch_clone(canonical), Err(CacheError::Git(_))));
1191
1192        let recloned = reclone(canonical).expect("reclone");
1193        assert_eq!(recloned, slot, "reclone reinstalls at the same slot path");
1194        assert_eq!(head_sha(&recloned), head_sha(origin_dir.path()));
1195        // The fresh clone's own remote points back at the canonical URL, not
1196        // the broken one the corrupt slot had.
1197        let out = Command::new("git")
1198            .arg("-C")
1199            .arg(&recloned)
1200            .args(["remote", "get-url", "origin"])
1201            .output()
1202            .expect("remote get-url");
1203        assert_eq!(
1204            String::from_utf8_lossy(&out.stdout).trim(),
1205            canonical,
1206            "reclone must re-point origin at canonical_url, not the broken remote"
1207        );
1208
1209        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1210    }
1211
1212    /// Ownership guard (ADR-088 Amendment 1 / PR #761): `reclone` must never
1213    /// delete a directory that doesn't prove itself an owned cache slot, even
1214    /// though its path is exactly where the cache key says the slot should
1215    /// be -- a `KHIVE_GIT_DIGEST_SCRATCH_ROOT` override pointed at a broader
1216    /// directory must never lose unrelated operator data to a repair.
1217    #[test]
1218    fn reclone_refuses_to_replace_a_foreign_looking_directory() {
1219        let _guard = ENV_MUTEX.blocking_lock();
1220        let scratch = tempfile::tempdir().expect("tempdir");
1221        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1222
1223        let origin_dir = tempfile::tempdir().expect("tempdir");
1224        init_origin_with_one_commit(origin_dir.path());
1225        let canonical = origin_dir.path().to_str().unwrap();
1226        let key = cache_key(canonical);
1227        let foreign = scratch.path().join(&key);
1228        std::fs::create_dir_all(&foreign).unwrap();
1229        std::fs::write(foreign.join("important.txt"), b"do not delete me").unwrap();
1230
1231        let err = reclone(canonical).expect_err("foreign directory must be refused");
1232        assert!(
1233            matches!(err, CacheError::UnsafeToReplace(_)),
1234            "expected UnsafeToReplace, got {err:?}"
1235        );
1236        assert!(
1237            foreign.join("important.txt").exists(),
1238            "foreign directory contents must survive a refused reclone"
1239        );
1240
1241        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1242    }
1243
1244    /// When no slot exists at all yet, `reclone` has nothing to remove and
1245    /// simply installs a fresh clone -- the same fallback a first-ever
1246    /// `ensure_clone` would have taken.
1247    #[test]
1248    fn reclone_installs_fresh_when_no_slot_exists_yet() {
1249        let _guard = ENV_MUTEX.blocking_lock();
1250        let scratch = tempfile::tempdir().expect("tempdir");
1251        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1252
1253        let origin_dir = tempfile::tempdir().expect("tempdir");
1254        init_origin_with_one_commit(origin_dir.path());
1255        let canonical = origin_dir.path().to_str().unwrap();
1256
1257        let recloned = reclone(canonical).expect("reclone with no prior slot");
1258        assert_eq!(head_sha(&recloned), head_sha(origin_dir.path()));
1259
1260        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1261    }
1262
1263    /// Review round-2 \[High\]-1 remediation (issue #765): `ensure_clone` must
1264    /// never adopt, fetch into, or touch a directory sitting at the
1265    /// cache-key path that does not already prove itself owned via
1266    /// `is_owned_entry`. Here the directory is a genuine Git repository (so
1267    /// the pre-fix `repo_dir.join(".git").exists()` check alone would have
1268    /// accepted it) but is missing the `.khive-last-used` marker -- standing
1269    /// in for an operator's own repository that happens to land on the same
1270    /// cache-key path under an overridden `KHIVE_GIT_DIGEST_SCRATCH_ROOT`.
1271    /// The call must refuse with `UnsafeToReplace` and the sentinel operator
1272    /// data inside must survive completely untouched.
1273    #[test]
1274    fn ensure_clone_refuses_a_markerless_git_directory_at_the_cache_key_path() {
1275        let _guard = ENV_MUTEX.blocking_lock();
1276        let scratch = tempfile::tempdir().expect("tempdir");
1277        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1278
1279        let canonical = "https://example.invalid/lookalike/repo";
1280        let key = cache_key(canonical);
1281        let lookalike = scratch.path().join(&key);
1282        std::fs::create_dir_all(&lookalike).unwrap();
1283        init_origin_with_one_commit(&lookalike);
1284        std::fs::write(lookalike.join("sentinel.txt"), b"do not delete me").unwrap();
1285        let sentinel_sha = head_sha(&lookalike);
1286
1287        let err = ensure_clone(canonical).expect_err("markerless lookalike must be refused");
1288        assert!(
1289            matches!(err, CacheError::UnsafeToReplace(_)),
1290            "expected UnsafeToReplace, got {err:?}"
1291        );
1292
1293        assert!(
1294            lookalike.join("sentinel.txt").exists(),
1295            "sentinel operator data must survive a refused ensure_clone"
1296        );
1297        assert_eq!(
1298            head_sha(&lookalike),
1299            sentinel_sha,
1300            "the lookalike repository's own history must be untouched (no fetch ran)"
1301        );
1302        assert!(
1303            !lookalike.join(MARKER_FILE).exists(),
1304            "a refused ensure_clone must never write the ownership marker either"
1305        );
1306
1307        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1308    }
1309
1310    /// Same guard, symlink variant: a symlink placed at the cache-key path
1311    /// pointing at an unrelated owned-looking directory must not be treated
1312    /// as an owned slot either -- `is_owned_entry` requires the cache-key
1313    /// path itself to be a real directory, not a symlink to one.
1314    #[cfg(unix)]
1315    #[test]
1316    fn ensure_clone_refuses_a_symlink_at_the_cache_key_path() {
1317        let _guard = ENV_MUTEX.blocking_lock();
1318        let scratch = tempfile::tempdir().expect("tempdir");
1319        std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1320
1321        let canonical = "https://example.invalid/symlink-lookalike/repo";
1322        let key = cache_key(canonical);
1323        let link_path = scratch.path().join(&key);
1324
1325        let target = tempfile::tempdir().expect("symlink target");
1326        make_owned_entry(target.path(), "9999999999999999", true);
1327        let real_owned = target.path().join("9999999999999999");
1328        std::fs::write(real_owned.join("sentinel.txt"), b"do not delete me").unwrap();
1329
1330        std::os::unix::fs::symlink(&real_owned, &link_path).expect("create symlink");
1331
1332        let err = ensure_clone(canonical).expect_err("symlink lookalike must be refused");
1333        assert!(
1334            matches!(err, CacheError::UnsafeToReplace(_)),
1335            "expected UnsafeToReplace, got {err:?}"
1336        );
1337        assert!(
1338            real_owned.join("sentinel.txt").exists(),
1339            "the symlink target's sentinel data must survive a refused ensure_clone"
1340        );
1341
1342        std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1343    }
1344}