use crate::cow::parse_base_project_name;
use crate::state::{read_state, write_state};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tracing::{debug, info};
#[derive(Debug, Clone, Default)]
pub struct GcReport {
pub total_workspaces_scanned: usize,
pub caches_trimmed_bytes: u64,
pub workspaces_deleted: usize,
pub workspaces_deleted_bytes: u64,
pub remaining_disk_bytes: u64,
}
#[derive(Debug, Clone, Default)]
pub struct CasGcReport {
pub objects_deleted: usize,
pub bytes_freed: u64,
pub remaining_bytes: u64,
}
#[derive(Debug, Clone)]
pub struct WorkspaceMetadata {
pub path: PathBuf,
pub name: String,
pub project: String,
pub last_used_at: SystemTime,
pub size_bytes: u64,
pub is_canonical: bool,
}
pub fn calculate_dir_size(path: &Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_dir() {
total += calculate_dir_size(&entry.path());
} else {
total += meta.len();
}
}
}
}
total
}
pub fn touch_workspace(workspace_dir: &Path, _project_name: &str) {
if let Some(mut state) = read_state(workspace_dir) {
state.last_installed_at = SystemTime::now();
let _ = write_state(workspace_dir, &state);
}
let stamp = workspace_dir.join(".farhand-last-used");
let _ = fs::write(stamp, format!("{:?}", SystemTime::now()));
}
pub fn get_workspace_last_used(workspace_dir: &Path) -> SystemTime {
if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-last-used")) {
if let Ok(mtime) = meta.modified() {
return mtime;
}
}
if let Some(state) = read_state(workspace_dir) {
return state.last_installed_at;
}
if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-state.json")) {
if let Ok(mtime) = meta.modified() {
return mtime;
}
}
if let Ok(meta) = fs::metadata(workspace_dir) {
if let Ok(mtime) = meta.modified() {
return mtime;
}
}
SystemTime::UNIX_EPOCH
}
pub fn trim_workspace_caches(workspace_dir: &Path) -> u64 {
let cache_dirs = [
"target/debug/incremental",
"target/release/incremental",
"node_modules/.cache",
".next/cache",
"DerivedData",
".gradle/caches",
];
let mut freed_bytes = 0u64;
for rel_dir in cache_dirs {
let dir = workspace_dir.join(rel_dir);
if dir.is_dir() {
let size = calculate_dir_size(&dir);
if fs::remove_dir_all(&dir).is_ok() {
freed_bytes += size;
info!("Trimmed cache {} (freed {} bytes)", dir.display(), size);
}
}
}
freed_bytes
}
pub fn scan_workspaces(workspaces_root: &Path) -> Vec<WorkspaceMetadata> {
let mut workspaces = Vec::new();
let entries = match fs::read_dir(workspaces_root) {
Ok(e) => e,
Err(_) => return workspaces,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if file_name == "cas" || file_name.starts_with('.') {
continue;
}
let last_used = get_workspace_last_used(&path);
let size = calculate_dir_size(&path);
let is_canonical = parse_base_project_name(&file_name).is_none()
|| file_name.contains("__main")
|| file_name.contains("__master")
|| file_name.contains(":main")
|| file_name.contains(":master");
let project = read_state(&path)
.map(|s| s.project)
.filter(|p| !p.is_empty())
.unwrap_or_else(|| file_name.clone());
workspaces.push(WorkspaceMetadata {
path,
name: file_name,
project,
last_used_at: last_used,
size_bytes: size,
is_canonical,
});
}
}
workspaces
}
pub fn run_garbage_collection(
workspaces_root: &Path,
max_disk_bytes: Option<u64>,
ttl: Option<Duration>,
skip_locked: &dyn Fn(&str) -> bool,
) -> GcReport {
let mut report = GcReport::default();
let mut workspaces = scan_workspaces(workspaces_root);
report.total_workspaces_scanned = workspaces.len();
let now = SystemTime::now();
if let Some(ttl_dur) = ttl {
workspaces.retain(|ws| {
if !ws.is_canonical {
if skip_locked(&ws.project) {
debug!("GC: skipping locked workspace {} (active run)", ws.name);
return true;
}
if let Ok(age) = now.duration_since(ws.last_used_at) {
if age > ttl_dur {
info!(
"TTL expired for workspace {} (age {:?} > {:?}). Purging...",
ws.path.display(),
age,
ttl_dur
);
let _ = fs::remove_dir_all(&ws.path);
report.workspaces_deleted += 1;
report.workspaces_deleted_bytes += ws.size_bytes;
return false;
}
}
}
true
});
}
let current_total: u64 = workspaces.iter().map(|w| w.size_bytes).sum();
let mut current_usage = current_total;
if let Some(max_bytes) = max_disk_bytes {
if current_usage > max_bytes {
workspaces.sort_by_key(|a| a.last_used_at);
for ws in &mut workspaces {
if current_usage <= max_bytes {
break;
}
if !ws.is_canonical && !skip_locked(&ws.project) {
let trimmed = trim_workspace_caches(&ws.path);
report.caches_trimmed_bytes += trimmed;
current_usage = current_usage.saturating_sub(trimmed);
ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
}
}
for ws in &workspaces {
if current_usage <= max_bytes {
break;
}
if !ws.is_canonical && !skip_locked(&ws.project) {
info!(
"Quota exceeded. Purging LRU workspace {} (size {} bytes)...",
ws.path.display(),
ws.size_bytes
);
let _ = fs::remove_dir_all(&ws.path);
report.workspaces_deleted += 1;
report.workspaces_deleted_bytes += ws.size_bytes;
current_usage = current_usage.saturating_sub(ws.size_bytes);
}
}
}
}
report.remaining_disk_bytes = current_usage;
report
}
pub fn run_emergency_disk_gc(
workspaces_root: &Path,
target_bytes_to_free: u64,
skip_locked: &dyn Fn(&str) -> bool,
) -> GcReport {
let mut report = GcReport::default();
let mut workspaces = scan_workspaces(workspaces_root);
report.total_workspaces_scanned = workspaces.len();
workspaces.sort_by_key(|w| w.last_used_at);
let mut freed_bytes = 0u64;
for ws in &mut workspaces {
if freed_bytes >= target_bytes_to_free {
break;
}
if !ws.is_canonical && !skip_locked(&ws.project) {
let trimmed = trim_workspace_caches(&ws.path);
report.caches_trimmed_bytes += trimmed;
freed_bytes += trimmed;
ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
}
}
for ws in &workspaces {
if freed_bytes >= target_bytes_to_free {
break;
}
if !ws.is_canonical && !skip_locked(&ws.project) {
info!(
"Emergency GC: Purging LRU workspace {} (size {} bytes)...",
ws.path.display(),
ws.size_bytes
);
let _ = fs::remove_dir_all(&ws.path);
report.workspaces_deleted += 1;
report.workspaces_deleted_bytes += ws.size_bytes;
freed_bytes += ws.size_bytes;
}
}
report.remaining_disk_bytes = freed_bytes;
report
}
pub fn gc_cas(
cas_objects_dir: &Path,
max_bytes: Option<u64>,
ttl: Option<Duration>,
) -> CasGcReport {
let mut report = CasGcReport::default();
let mut objects: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
for entry in walkdir::WalkDir::new(cas_objects_dir)
.follow_links(false)
.into_iter()
.flatten()
{
let path = entry.path();
if !path.is_file() {
continue;
}
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
objects.push((
path.to_path_buf(),
meta.len(),
meta.modified().unwrap_or(SystemTime::now()),
));
}
let now = SystemTime::now();
let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
let is_tmp = |p: &Path| {
p.file_name()
.and_then(|s| s.to_str())
.map(|s| s.contains(".tmp."))
.unwrap_or(false)
};
for (path, size, mtime) in objects {
let age = now.duration_since(mtime).unwrap_or_default();
let stale_tmp = is_tmp(&path) && age > Duration::from_secs(3600);
let expired = ttl.map(|t| age > t).unwrap_or(false);
if stale_tmp || expired {
if fs::remove_file(&path).is_ok() {
report.objects_deleted += 1;
report.bytes_freed += size;
} else {
remaining.push((path, size, mtime));
}
} else {
remaining.push((path, size, mtime));
}
}
if let Some(max_bytes) = max_bytes {
let mut total: u64 = remaining.iter().map(|(_, s, _)| *s).sum();
if total > max_bytes {
remaining.sort_by_key(|(_, _, mtime)| *mtime);
let mut survivors = Vec::new();
for (path, size, mtime) in remaining {
if total > max_bytes && fs::remove_file(&path).is_ok() {
report.objects_deleted += 1;
report.bytes_freed += size;
total = total.saturating_sub(size);
} else {
survivors.push((path, size, mtime));
}
}
remaining = survivors;
}
}
report.remaining_bytes = remaining.iter().map(|(_, s, _)| *s).sum();
report
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_trim_workspace_caches() {
let temp = tempdir().unwrap();
let ws = temp.path().join("my-ws");
let cache_dir = ws.join("target/debug/incremental");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("cache.dat"), "1234567890").unwrap();
let src_file = ws.join("src/main.rs");
fs::create_dir_all(ws.join("src")).unwrap();
fs::write(&src_file, "fn main() {}").unwrap();
let freed = trim_workspace_caches(&ws);
assert!(freed >= 10);
assert!(!cache_dir.exists());
assert!(src_file.exists());
}
#[test]
fn test_run_garbage_collection_quota_eviction() {
let temp = tempdir().unwrap();
let root = temp.path();
let main_ws = root.join("my-repo__main-12345678");
fs::create_dir_all(&main_ws).unwrap();
fs::write(main_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
let feat_ws = root.join("my-repo__feat1-87654321");
fs::create_dir_all(&feat_ws).unwrap();
fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
let report = run_garbage_collection(root, Some(1200), None, &|_| false);
assert_eq!(report.workspaces_deleted, 1);
assert!(!feat_ws.exists());
assert!(main_ws.exists(), "Canonical workspace must be preserved");
}
#[test]
fn test_run_garbage_collection_skips_locked_workspaces() {
let temp = tempdir().unwrap();
let root = temp.path();
let feat_ws = root.join("my-repo__feat1-87654321");
fs::create_dir_all(&feat_ws).unwrap();
fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
let report = run_garbage_collection(root, Some(500), None, &|name| {
name.starts_with("my-repo__feat1")
});
assert_eq!(report.workspaces_deleted, 0);
assert!(feat_ws.exists(), "locked workspace must survive GC");
}
#[test]
fn test_gc_lock_check_matches_the_project_name_not_the_directory() {
let temp = tempdir().unwrap();
let root = temp.path();
let feat_ws = root.join("my-repo__feat1-87654321");
fs::create_dir_all(&feat_ws).unwrap();
fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
write_state(
&feat_ws,
&crate::state::WorkspaceState {
version: 1,
last_success_lockfile_hash: String::new(),
last_installed_at: SystemTime::now(),
template: "npm".to_string(),
project: "my-repo:feat1".to_string(),
toolchain: std::collections::BTreeMap::new(),
},
)
.unwrap();
let locked: std::collections::HashSet<String> =
["my-repo:feat1".to_string()].into_iter().collect();
let report = run_garbage_collection(root, Some(500), None, &|name| locked.contains(name));
assert_eq!(report.workspaces_deleted, 0, "GC deleted a live workspace");
assert!(
feat_ws.exists(),
"a workspace with an active run was evicted by quota"
);
}
#[test]
fn test_gc_still_evicts_when_no_run_holds_the_lock() {
let temp = tempdir().unwrap();
let root = temp.path();
let feat_ws = root.join("my-repo__feat1-87654321");
fs::create_dir_all(&feat_ws).unwrap();
fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
write_state(
&feat_ws,
&crate::state::WorkspaceState {
version: 1,
last_success_lockfile_hash: String::new(),
last_installed_at: SystemTime::now(),
template: "npm".to_string(),
project: "my-repo:feat1".to_string(),
toolchain: std::collections::BTreeMap::new(),
},
)
.unwrap();
let report = run_garbage_collection(root, Some(500), None, &|_| false);
assert_eq!(report.workspaces_deleted, 1);
assert!(!feat_ws.exists());
}
#[test]
fn test_run_emergency_disk_gc() {
let temp = tempdir().unwrap();
let root = temp.path();
let feat_ws = root.join("my-repo__feat1-87654321");
fs::create_dir_all(&feat_ws).unwrap();
fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
let report = run_emergency_disk_gc(root, 1000, &|_| false);
assert_eq!(report.workspaces_deleted, 1);
assert!(!feat_ws.exists());
}
#[test]
fn test_run_emergency_disk_gc_skips_locked() {
let temp = tempdir().unwrap();
let root = temp.path();
let feat_ws = root.join("my-repo__feat1-87654321");
fs::create_dir_all(&feat_ws).unwrap();
fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
let report = run_emergency_disk_gc(root, 1000, &|_| true);
assert_eq!(report.workspaces_deleted, 0);
assert!(feat_ws.exists());
}
}
#[cfg(test)]
mod cas_gc_tests {
use super::*;
use std::time::Duration;
fn make_object(dir: &Path, hash: &str, size: usize, age_secs: u64) -> PathBuf {
let path = dir.join(&hash[..2]).join(&hash[2..4]).join(hash);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, vec![0u8; size]).unwrap();
let old = SystemTime::now() - Duration::from_secs(age_secs);
let f = fs::File::options().write(true).open(&path).unwrap();
f.set_modified(old).unwrap();
path
}
#[test]
fn test_gc_cas_ttl_evicts_unused_objects() {
let dir = tempfile::tempdir().unwrap();
let fresh = make_object(dir.path(), "aa11fresh_object_1", 100, 0);
let stale = make_object(dir.path(), "bb22stale_object_2", 100, 40 * 86400);
let report = gc_cas(dir.path(), None, Some(Duration::from_secs(30 * 86400)));
assert_eq!(report.objects_deleted, 1);
assert_eq!(report.bytes_freed, 100);
assert!(fresh.is_file(), "fresh object must survive");
assert!(!stale.exists(), "stale object must be evicted");
}
#[test]
fn test_gc_cas_quota_evicts_lru_first() {
let dir = tempfile::tempdir().unwrap();
let old = make_object(dir.path(), "cc33old_object_1111", 400, 86400);
let new = make_object(dir.path(), "dd44new_object_1111", 400, 1);
let report = gc_cas(dir.path(), Some(500), None);
assert_eq!(report.objects_deleted, 1);
assert!(!old.exists(), "oldest object evicted under quota");
assert!(new.is_file(), "newest object survives under quota");
}
#[test]
fn test_gc_cas_removes_stale_tmp_files() {
let dir = tempfile::tempdir().unwrap();
let tmp = dir.path().join("ab").join("cd");
fs::create_dir_all(&tmp).unwrap();
let tmp_file = tmp.join("abcddeadbeef.tmp.12345.7");
fs::write(&tmp_file, vec![0u8; 50]).unwrap();
let old = SystemTime::now() - Duration::from_secs(7200);
let f = fs::File::options().write(true).open(&tmp_file).unwrap();
f.set_modified(old).unwrap();
let report = gc_cas(dir.path(), None, None);
assert!(!tmp_file.exists(), "stale tmp file must be cleaned");
assert_eq!(report.objects_deleted, 1);
}
#[test]
fn test_scan_workspaces_skips_cas_dir() {
let root = tempfile::tempdir().unwrap();
fs::create_dir_all(root.path().join("myrepo")).unwrap();
fs::create_dir_all(root.path().join("cas").join("objects")).unwrap();
let scanned = scan_workspaces(root.path());
let names: Vec<&str> = scanned.iter().map(|w| w.name.as_str()).collect();
assert_eq!(
names,
vec!["myrepo"],
"cas dir must not count as a workspace"
);
}
}
#[cfg(all(test, unix))]
mod symlink_accounting_tests {
use super::*;
use tempfile::tempdir;
#[cfg(unix)]
#[test]
fn symlinks_are_counted_as_links_and_never_recursed_into() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::write(root.join("real.bin"), vec![7u8; 4096]).unwrap();
let before = calculate_dir_size(root);
let big = tempdir().unwrap();
fs::write(big.path().join("payload.bin"), vec![1u8; 4 * 1024 * 1024]).unwrap();
std::os::unix::fs::symlink(root, root.join("self")).unwrap();
std::os::unix::fs::symlink(big.path(), root.join("big")).unwrap();
let after = calculate_dir_size(root);
assert!(
after < 64 * 1024,
"a 4 MiB tree behind a link was counted: {before} -> {after}"
);
assert!(
after >= before,
"the real file's own bytes must still count"
);
}
}