use std::path::Path;
use serde::Serialize;
use crate::store::{CACHE_DIR, WORKSPACES_DIR, acquire_lock, cache_root, read_workspace_marker};
use crate::store_gc::{GcError, dir_size};
#[derive(Debug, Clone, Default, Serialize)]
pub struct ReapReport {
pub scanned: usize,
pub reaped: usize,
pub kept_live: usize,
pub kept_unverifiable: usize,
pub skipped_locked: usize,
pub bytes_freed: u64,
}
pub fn reap_orphaned_workspaces() -> Result<ReapReport, GcError> {
reap_orphaned_workspaces_in(&cache_root().join(CACHE_DIR).join(WORKSPACES_DIR))
}
pub(crate) fn reap_orphaned_workspaces_in(workspaces_dir: &Path) -> Result<ReapReport, GcError> {
let mut report = ReapReport::default();
if !workspaces_dir.exists() {
return Ok(report);
}
let entries = std::fs::read_dir(workspaces_dir).map_err(|source| GcError::Io {
path: workspaces_dir.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| GcError::Io {
path: workspaces_dir.to_path_buf(),
source,
})?;
let workspace_dir = entry.path();
if !workspace_dir.is_dir() {
continue;
}
report.scanned += 1;
let Some(marker) = read_workspace_marker(&workspace_dir) else {
report.kept_unverifiable += 1;
tracing::debug!(
workspace = %workspace_dir.display(),
"workspace cache has no root marker; keeping (unverifiable, self-heals on next open)"
);
continue;
};
if marker.root.exists() {
report.kept_live += 1;
continue;
}
if marker.root.parent().is_some_and(|parent| !parent.exists()) {
report.kept_unverifiable += 1;
tracing::debug!(
workspace = %workspace_dir.display(),
root = %marker.root.display(),
"workspace root AND its parent are both missing (unmounted volume?); keeping — a \
detached disk must not be mistaken for a deleted repo"
);
continue;
}
let Ok(lock) = acquire_lock(&workspace_dir) else {
report.skipped_locked += 1;
tracing::debug!(
workspace = %workspace_dir.display(),
"orphaned workspace cache is locked by another process; skipping this sweep"
);
continue;
};
let bytes = dir_size(&workspace_dir)?;
std::fs::remove_dir_all(&workspace_dir).map_err(|source| GcError::Io {
path: workspace_dir.clone(),
source,
})?;
drop(lock);
report.reaped += 1;
report.bytes_freed += bytes;
tracing::info!(
workspace = %workspace_dir.display(),
root = %marker.root.display(),
bytes,
"reaped orphaned workspace cache (its worktree root no longer exists)"
);
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{FileEntry, INDEX_FILE, Index, VIEWS_DIR, ensure_workspace_marker};
use crate::store_gc::{collect_referenced_hashes_global_in, gc_global_blobs_in};
use std::fs;
use std::path::PathBuf;
fn seed_workspace(workspaces_dir: &Path, key: &str, root: Option<&Path>, stems: &[&str]) -> PathBuf {
let workspace_dir = workspaces_dir.join(key);
let working = workspace_dir.join(VIEWS_DIR).join("working");
fs::create_dir_all(&working).expect("mk workspace view");
let mut index = Index::empty();
for (i, stem) in stems.iter().enumerate() {
index.files.insert(
crate::path::RelPath::from(format!("src/f{i}.rs").as_str()),
FileEntry {
hash_hex: (*stem).to_string(),
language: "rust".to_string(),
size_bytes: 2,
mtime: 0,
},
);
}
let bytes = rmp_serde::to_vec_named(&index).expect("encode index");
fs::write(working.join(INDEX_FILE), bytes).expect("write index");
if let Some(root) = root {
ensure_workspace_marker(&workspace_dir, root);
assert!(
read_workspace_marker(&workspace_dir).is_some(),
"fixture must land a readable marker"
);
}
workspace_dir
}
fn make_root(tmp: &Path, name: &str) -> PathBuf {
let root = tmp.join(name);
fs::create_dir_all(root.join("src")).expect("mk root");
root
}
#[test]
fn an_orphaned_workspace_whose_root_is_gone_is_reaped() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let live_root = make_root(tmp.path(), "live-repo");
let dead_root = make_root(tmp.path(), "dead-repo");
let live_ws = seed_workspace(&workspaces, "key-live", Some(&live_root), &["a".repeat(64).as_str()]);
let dead_ws = seed_workspace(&workspaces, "key-dead", Some(&dead_root), &["b".repeat(64).as_str()]);
fs::remove_dir_all(&dead_root).expect("delete the orphan's root");
let report = reap_orphaned_workspaces_in(&workspaces).expect("reap");
assert_eq!(report.scanned, 2, "both workspace dirs inspected");
assert_eq!(report.reaped, 1, "exactly the orphan reaped");
assert_eq!(report.kept_live, 1, "the live workspace is kept");
assert_eq!(report.kept_unverifiable, 0, "both dirs carry markers");
assert!(report.bytes_freed > 0, "the reaped tree's bytes are accounted");
assert!(!dead_ws.exists(), "orphaned workspace dir removed");
assert!(live_ws.exists(), "live workspace dir untouched");
}
#[test]
fn a_workspace_on_an_unmounted_volume_is_never_reaped() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let volume = tmp.path().join("Volumes").join("external");
let root = volume.join("repo");
fs::create_dir_all(root.join("src")).expect("mk root on the volume");
let workspace = seed_workspace(&workspaces, "key-ext", Some(&root), &["c".repeat(64).as_str()]);
fs::remove_dir_all(tmp.path().join("Volumes")).expect("unmount the volume");
let report = reap_orphaned_workspaces_in(&workspaces).expect("reap");
assert_eq!(report.reaped, 0, "an unmounted volume must never be reaped");
assert_eq!(report.kept_unverifiable, 1, "it is kept as unverifiable");
assert!(workspace.exists(), "the workspace index survives the unmount");
}
#[test]
fn a_workspace_with_a_live_root_is_never_reaped() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let root = make_root(tmp.path(), "live-repo");
let workspace = seed_workspace(&workspaces, "key-live", Some(&root), &["a".repeat(64).as_str()]);
let report = reap_orphaned_workspaces_in(&workspaces).expect("reap");
assert_eq!(report.reaped, 0, "a live root must never be reaped");
assert_eq!(report.kept_live, 1);
assert_eq!(report.bytes_freed, 0);
assert!(workspace.join(VIEWS_DIR).join("working").join(INDEX_FILE).exists());
}
#[test]
fn a_legacy_workspace_without_a_marker_is_never_reaped() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let legacy = seed_workspace(&workspaces, "key-legacy", None, &["a".repeat(64).as_str()]);
let report = reap_orphaned_workspaces_in(&workspaces).expect("reap");
assert_eq!(report.scanned, 1);
assert_eq!(report.reaped, 0, "a dir we cannot verify must never be deleted");
assert_eq!(report.kept_unverifiable, 1, "and it is reported as unverifiable");
assert!(legacy.exists(), "legacy workspace dir survives");
assert!(
legacy.join(VIEWS_DIR).join("working").join(INDEX_FILE).exists(),
"its index survives intact"
);
}
#[test]
fn reaping_an_orphaned_workspace_releases_the_blobs_it_pinned() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let blobs = tmp.path().join("blobs");
fs::create_dir_all(&blobs).expect("mk blobs");
let live_stem = "a".repeat(64);
let pinned_stem = "b".repeat(64);
let pinned_bytes = b"blob-pinned-by-the-orphan";
fs::write(blobs.join(format!("{live_stem}.fm.msgpack")), b"fm-live").expect("live blob");
fs::write(blobs.join(format!("{pinned_stem}.fm.msgpack")), pinned_bytes).expect("pinned blob");
let live_root = make_root(tmp.path(), "live-repo");
let dead_root = make_root(tmp.path(), "dead-repo");
seed_workspace(&workspaces, "key-live", Some(&live_root), &[live_stem.as_str()]);
let dead_ws = seed_workspace(&workspaces, "key-dead", Some(&dead_root), &[pinned_stem.as_str()]);
fs::remove_dir_all(&dead_root).expect("delete the orphan's root");
let before = gc_global_blobs_in(&workspaces, &blobs).expect("gc before");
assert_eq!(before.removed, 0, "nothing reclaimable while the orphan pins the blob");
assert!(
blobs.join(format!("{pinned_stem}.fm.msgpack")).exists(),
"the orphan pins its blob against the pre-reap sweep"
);
let reap = reap_orphaned_workspaces_in(&workspaces).expect("reap");
assert_eq!(reap.reaped, 1, "the orphan is reaped");
assert!(!dead_ws.exists());
let referenced = collect_referenced_hashes_global_in(&workspaces).expect("union");
assert!(
!referenced.contains(&pinned_stem),
"the reaped workspace no longer votes in the live set"
);
let after = gc_global_blobs_in(&workspaces, &blobs).expect("gc after");
assert_eq!(after.removed, 1, "the previously-pinned blob is reclaimed");
assert_eq!(after.bytes_freed, pinned_bytes.len() as u64);
assert!(
!blobs.join(format!("{pinned_stem}.fm.msgpack")).exists(),
"the leaked blob is finally gone"
);
assert!(
blobs.join(format!("{live_stem}.fm.msgpack")).exists(),
"the live workspace's blob survives"
);
}
#[test]
fn a_blob_shared_with_a_live_workspace_survives_the_reap() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let blobs = tmp.path().join("blobs");
fs::create_dir_all(&blobs).expect("mk blobs");
let shared_stem = "a".repeat(64);
fs::write(blobs.join(format!("{shared_stem}.fm.msgpack")), b"fm-shared").expect("shared blob");
let live_root = make_root(tmp.path(), "live-repo");
let dead_root = make_root(tmp.path(), "dead-repo");
seed_workspace(&workspaces, "key-live", Some(&live_root), &[shared_stem.as_str()]);
seed_workspace(&workspaces, "key-dead", Some(&dead_root), &[shared_stem.as_str()]);
fs::remove_dir_all(&dead_root).expect("delete the orphan's root");
assert_eq!(reap_orphaned_workspaces_in(&workspaces).expect("reap").reaped, 1);
let report = gc_global_blobs_in(&workspaces, &blobs).expect("gc");
assert_eq!(report.removed, 0, "the live workspace still references the blob");
assert!(
blobs.join(format!("{shared_stem}.fm.msgpack")).exists(),
"a blob shared with a live workspace must never be over-reaped"
);
}
#[test]
fn a_locked_orphaned_workspace_is_skipped() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspaces = tmp.path().join("workspaces");
let dead_root = make_root(tmp.path(), "dead-repo");
let dead_ws = seed_workspace(&workspaces, "key-dead", Some(&dead_root), &["a".repeat(64).as_str()]);
fs::remove_dir_all(&dead_root).expect("delete the orphan's root");
let _held = acquire_lock(&dead_ws).expect("hold the workspace lock");
let report = reap_orphaned_workspaces_in(&workspaces).expect("reap");
assert_eq!(report.reaped, 0, "a locked workspace is never reaped");
assert_eq!(report.skipped_locked, 1);
assert!(dead_ws.exists(), "the locked workspace dir survives");
}
}