mitts 0.3.3

Rust-first build and package tooling with a shared artifact cache
Documentation
/*
 * Mitts Build — Rust-first build and package tooling with shared artifact cache
 * Copyright (c) 2026 Alexander Panasenko
 *
 * Contact: alex@prod.codes
 * Author: https://prod.codes/about/
 * Project: https://github.com/alex09x/mitts
 * SPDX-License-Identifier: MIT OR Apache-2.0
 */

use super::{action_cache::ActionCache, cas::ContentAddressedStore, lock::FileLock};
use anyhow::{Context, Result};
use std::collections::HashSet;
use std::{
    fs,
    path::PathBuf,
    time::{Duration, SystemTime},
};

/// Report generated after running garbage collection on the cache.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GcReport {
    pub objects_examined: usize,
    pub objects_pruned: usize,
    pub bytes_freed: u64,
    pub bytes_retained: u64,
    pub skipped_active_build: bool,
}

/// Options configuring cache garbage collection and storage quotas.
#[derive(Debug, Clone, Default)]
pub struct GcOptions {
    pub max_bytes: Option<u64>,
    pub max_age: Option<Duration>,
}

/// Shared lease guard held by active builds, tests, doctests, and `mitts run`.
#[allow(dead_code)]
pub struct CacheLease(Option<FileLock>);

impl CacheLease {
    pub fn acquire(cache_dir: Option<&std::path::Path>) -> Self {
        let cas_root = cache_dir.map(|p| p.join("cas")).or_else(|| {
            ContentAddressedStore::default_store()
                .ok()
                .map(|c| c.root().to_path_buf())
        });
        cas_root.map_or(Self(None), |cr| Self::acquire_for_cas_root(&cr))
    }

    pub fn acquire_for_cas_root(cas_root: &std::path::Path) -> Self {
        Self(FileLock::acquire_shared(&CacheGc::gc_lock_path_for_cas_root(cas_root)).ok())
    }
}

pub struct CacheGc<'a> {
    cas: &'a ContentAddressedStore,
    ac: &'a ActionCache,
}

impl<'a> CacheGc<'a> {
    pub fn new(cas: &'a ContentAddressedStore, ac: &'a ActionCache) -> Self {
        Self { cas, ac }
    }

    pub fn gc_lock_path_for_cas_root(cas_root: &std::path::Path) -> PathBuf {
        cas_root.parent().unwrap_or(cas_root).join(".gc.lock")
    }

    pub fn gc_lock_path(&self) -> PathBuf {
        Self::gc_lock_path_for_cas_root(self.cas.root())
    }

    fn active_locks(&self) -> HashSet<String> {
        fs::read_dir(self.ac.locks_dir())
            .into_iter()
            .flatten()
            .flatten()
            .map(|e| e.path())
            .filter(|p| {
                p.extension().is_some_and(|e| e == "lock")
                    && matches!(FileLock::try_acquire_exclusive(p), Ok(None))
            })
            .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(str::to_string))
            .collect()
    }

    fn active_cas_hashes(&self, active_keys: &HashSet<String>) -> HashSet<String> {
        active_keys
            .iter()
            .filter_map(|k| self.ac.get(k).ok().flatten())
            .flat_map(|e| e.outputs.into_iter().map(|o| o.cas_hash))
            .collect()
    }

    pub fn prune(&self, opts: &GcOptions) -> Result<GcReport> {
        let sources_dir =
            super::cache_root::from_cas_root(self.cas.root()).join(super::cache_root::SOURCES_DIR);
        if sources_dir.is_dir() {
            if let Ok(store) = super::source_store::SourceStore::new(sources_dir) {
                let _ = store.cleanup_dead_staging();
            }
        }
        let objects_dir = self.cas.root().join("objects");
        if !objects_dir.is_dir() {
            return Ok(GcReport::default());
        }
        let gc_lock = FileLock::try_acquire_exclusive(&self.gc_lock_path())?;
        let has_active_lease = gc_lock.is_none();
        let active_keys = self.active_locks();
        let protected_hashes = self.active_cas_hashes(&active_keys);
        struct CasObjectMeta {
            path: PathBuf,
            hash: String,
            size: u64,
            mtime: SystemTime,
            #[cfg(unix)]
            ino_key: (u64, u64),
        }
        let mut objects: Vec<CasObjectMeta> = Vec::new();
        let mut total_bytes: u64 = 0;
        for entry in fs::read_dir(&objects_dir)
            .with_context(|| format!("Failed to read CAS objects dir {:?}", objects_dir))?
            .flatten()
        {
            let path = entry.path();
            let Some(hash) = path
                .is_file()
                .then(|| path.file_name().and_then(|n| n.to_str()))
                .flatten()
                .filter(|s| !s.is_empty())
                .map(str::to_string)
            else {
                continue;
            };
            if let Ok(meta) = fs::metadata(&path) {
                let size = meta.len();
                let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
                total_bytes += size;
                #[cfg(unix)]
                let ino_key = {
                    use std::os::unix::fs::MetadataExt;
                    (meta.dev(), meta.ino())
                };
                objects.push(CasObjectMeta {
                    path,
                    hash,
                    size,
                    mtime,
                    #[cfg(unix)]
                    ino_key,
                });
            }
        }
        let objects_examined = objects.len();
        objects.sort_by_key(|o| o.mtime);
        let now = SystemTime::now();
        let mut pruned_hashes: HashSet<String> = HashSet::new();
        let mut bytes_freed: u64 = 0;
        let remove_readonly_file = |p: &std::path::Path| -> bool {
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o644));
            }
            fs::remove_file(p).is_ok()
        };
        if !has_active_lease {
            if let Some(max_age) = opts.max_age {
                for obj in &objects {
                    if !protected_hashes.contains(&obj.hash)
                        && now.duration_since(obj.mtime).is_ok_and(|age| age > max_age)
                        && remove_readonly_file(&obj.path)
                    {
                        pruned_hashes.insert(obj.hash.clone());
                        bytes_freed += obj.size;
                        total_bytes = total_bytes.saturating_sub(obj.size);
                    }
                }
            }
            if let Some(max_bytes) = opts.max_bytes {
                for obj in &objects {
                    if total_bytes <= max_bytes {
                        break;
                    }
                    if !pruned_hashes.contains(&obj.hash)
                        && !protected_hashes.contains(&obj.hash)
                        && remove_readonly_file(&obj.path)
                    {
                        pruned_hashes.insert(obj.hash.clone());
                        bytes_freed += obj.size;
                        total_bytes = total_bytes.saturating_sub(obj.size);
                    }
                }
            }
        }

        // 3. Clean orphaned artifacts links and dead action cache entries
        let artifact_dirs = [
            self.cas.root().join("artifacts"),
            self.cas.root().join("artifacts-meta"),
        ];
        if !has_active_lease
            && active_keys.is_empty()
            && (!pruned_hashes.is_empty() || opts.max_bytes.is_some() || opts.max_age.is_some())
        {
            let remaining_hashes: HashSet<&str> = objects
                .iter()
                .filter(|o| !pruned_hashes.contains(&o.hash))
                .map(|o| o.hash.as_str())
                .collect();
            #[cfg(unix)]
            let remaining_inodes: HashSet<(u64, u64)> = objects
                .iter()
                .filter(|o| !pruned_hashes.contains(&o.hash))
                .map(|o| o.ino_key)
                .collect();
            #[cfg(unix)]
            let mut counted_inodes: HashSet<(u64, u64)> =
                objects.iter().map(|o| o.ino_key).collect();

            for (artifacts_dir, path) in artifact_dirs
                .iter()
                .filter_map(|d| fs::read_dir(d).ok().map(move |it| (d, it)))
                .flat_map(|(d, it)| it.flatten().map(move |e| (d, e.path())))
            {
                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
                if name.starts_with(".tmp_") {
                    if fs::metadata(&path)
                        .and_then(|m| m.modified())
                        .ok()
                        .and_then(|mt| now.duration_since(mt).ok())
                        .is_some_and(|age| age > Duration::from_secs(3600))
                    {
                        let _ = remove_readonly_file(&path);
                    }
                    continue;
                }
                if let Some(art_name) = name
                    .strip_prefix('.')
                    .and_then(|s| s.strip_suffix(".cas_hash"))
                {
                    if !artifacts_dir.join(art_name).exists()
                        && !remaining_hashes
                            .contains(fs::read_to_string(&path).unwrap_or_default().trim())
                    {
                        let _ = remove_readonly_file(&path);
                    }
                    continue;
                }
                let sidecar = artifacts_dir.join(format!(".{name}.cas_hash"));
                let is_orphaned = if sidecar.is_file() {
                    !remaining_hashes
                        .contains(fs::read_to_string(&sidecar).unwrap_or_default().trim())
                } else {
                    #[cfg(unix)]
                    {
                        use std::os::unix::fs::MetadataExt;
                        fs::metadata(&path).is_ok_and(|m| {
                            m.nlink() <= 1 || !remaining_inodes.contains(&(m.dev(), m.ino()))
                        })
                    }
                    #[cfg(not(unix))]
                    false
                };
                if is_orphaned {
                    let meta_opt = fs::metadata(&path).ok();
                    if remove_readonly_file(&path) {
                        let _ = remove_readonly_file(&sidecar);
                        #[cfg(unix)]
                        if let Some(m) = meta_opt {
                            use std::os::unix::fs::MetadataExt;
                            if counted_inodes.insert((m.dev(), m.ino())) {
                                bytes_freed += m.len();
                            }
                        }
                    }
                }
            }
        } else if !pruned_hashes.is_empty() {
            #[cfg(unix)]
            {
                use std::os::unix::fs::MetadataExt;
                let still_linked: HashSet<(u64, u64)> = artifact_dirs
                    .iter()
                    .filter_map(|d| fs::read_dir(d).ok())
                    .flatten()
                    .flatten()
                    .filter_map(|e| fs::metadata(e.path()).ok().map(|m| (m.dev(), m.ino())))
                    .collect();
                for obj in objects.iter().filter(|o| {
                    pruned_hashes.contains(&o.hash) && still_linked.contains(&o.ino_key)
                }) {
                    bytes_freed = bytes_freed.saturating_sub(obj.size);
                    total_bytes += obj.size;
                }
            }
        }

        if !pruned_hashes.is_empty() {
            if let Ok(entries) = fs::read_dir(self.ac.root()) {
                for p in entries.flatten().map(|e| e.path()) {
                    let Some(key) = p
                        .extension()
                        .is_some_and(|e| e == "json")
                        .then(|| p.file_stem().and_then(|s| s.to_str()))
                        .flatten()
                    else {
                        continue;
                    };
                    if !active_keys.contains(key)
                        && self.ac.get(key).ok().flatten().is_some_and(|e| {
                            e.outputs
                                .iter()
                                .any(|o| pruned_hashes.contains(&o.cas_hash))
                        })
                    {
                        let _ = fs::remove_file(&p);
                    }
                }
            }
        }

        // 4. Prune build_scripts entries older than max_age
        if let Some(max_age) = opts.max_age {
            let rm_ro = crate::adapter::rust::build_script_cas::remove_dir_all_readonly_safe;
            if let Ok(base_entries) = fs::read_dir(self.cas.root().join("build_scripts")) {
                for base_path in base_entries
                    .flatten()
                    .map(|e| e.path())
                    .filter(|p| p.is_dir())
                {
                    let Ok(exec_entries) = fs::read_dir(&base_path) else {
                        continue;
                    };
                    let mut remaining_entries = 0;
                    for exec_path in exec_entries.flatten().map(|e| e.path()) {
                        let fname = exec_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
                        if fname.starts_with(".gc.") {
                            let _ = rm_ro(&exec_path);
                            continue;
                        }
                        if fname.starts_with(".tmp.") {
                            if fs::metadata(&exec_path)
                                .and_then(|m| m.modified())
                                .ok()
                                .and_then(|mt| now.duration_since(mt).ok())
                                .is_some_and(|age| age > Duration::from_secs(3600))
                            {
                                let _ = rm_ro(&exec_path);
                            }
                            continue;
                        }
                        if fname.starts_with('.') || !exec_path.is_dir() {
                            continue;
                        }
                        let effective_time = fs::metadata(exec_path.join(".last_used"))
                            .or_else(|_| fs::metadata(&exec_path))
                            .and_then(|m| m.modified())
                            .unwrap_or(SystemTime::UNIX_EPOCH);
                        let effective_max_age = if active_keys.is_empty() && !has_active_lease {
                            max_age
                        } else {
                            max_age.max(Duration::from_secs(3600))
                        };
                        if now
                            .duration_since(effective_time)
                            .is_ok_and(|age| age > effective_max_age)
                        {
                            let gc_temp =
                                base_path.join(format!(".gc.{fname}.{}", std::process::id()));
                            if fs::rename(&exec_path, &gc_temp).is_ok() {
                                let _ = rm_ro(&gc_temp);
                                continue;
                            }
                        }
                        remaining_entries += 1;
                    }
                    if remaining_entries == 0 {
                        let _ = fs::remove_dir(&base_path);
                    }
                }
            }
        }

        Ok(GcReport {
            objects_examined,
            objects_pruned: pruned_hashes.len(),
            bytes_freed,
            bytes_retained: total_bytes,
            skipped_active_build: has_active_lease,
        })
    }
}