use crate::paths::WORKTREE_MARKER;
use serde_json::Map;
use serde_json::Value;
use std::io::ErrorKind;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Orphan {
pub path: String,
pub is_worktree: bool,
}
pub fn find(projects: &Map<String, Value>, worktrees_only: bool) -> Vec<Orphan> {
let mut out = Vec::new();
for path in projects.keys() {
let is_worktree = path.contains(WORKTREE_MARKER);
if worktrees_only && !is_worktree {
continue;
}
if provably_absent(Path::new(path)) {
out.push(Orphan {
path: path.clone(),
is_worktree,
});
}
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
fn provably_absent(path: &Path) -> bool {
match std::fs::metadata(path) {
Ok(meta) => !meta.is_dir(),
Err(e) => matches!(e.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory),
}
}
pub fn counts(orphans: &[Orphan]) -> (usize, usize) {
let wt = orphans.iter().filter(|o| o.is_worktree).count();
(wt, orphans.len() - wt)
}
pub fn looks_like_wrong_host(removing: usize, total: usize) -> bool {
total >= 5 && removing * 100 >= total * 90
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map(entries: &[(&str, Value)]) -> Map<String, Value> {
entries
.iter()
.map(|(k, v)| ((*k).to_string(), v.clone()))
.collect()
}
#[test]
fn extant_dirs_are_not_orphans() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_string_lossy().into_owned();
let projects = map(&[(&path, json!({}))]);
assert!(find(&projects, false).is_empty());
}
#[test]
fn missing_dirs_are_orphans() {
let projects = map(&[("/no/such/path", json!({}))]);
let orphans = find(&projects, false);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].path, "/no/such/path");
assert!(!orphans[0].is_worktree);
}
#[test]
fn a_file_at_the_path_is_an_orphan() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("not-a-dir");
std::fs::write(&file, "x").unwrap();
let key = file.to_string_lossy().into_owned();
let projects = map(&[(&key, json!({}))]);
assert_eq!(
find(&projects, false).len(),
1,
"a file is provably not a project dir"
);
}
#[test]
fn a_path_through_a_file_is_an_orphan() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f");
std::fs::write(&file, "x").unwrap();
let key = file.join("child").to_string_lossy().into_owned();
let projects = map(&[(&key, json!({}))]);
assert_eq!(find(&projects, false).len(), 1);
}
#[cfg(unix)]
#[test]
fn an_unstatable_path_is_kept() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let locked = dir.path().join("locked");
std::fs::create_dir(&locked).unwrap();
let project = locked.join("project");
std::fs::create_dir(&project).unwrap();
let key = project.to_string_lossy().into_owned();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let projects = map(&[(&key, json!({}))]);
let orphans = find(&projects, false);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
orphans.is_empty(),
"unstatable entries must never be pruned: {orphans:?}"
);
}
#[test]
fn worktree_marker_classifies() {
let projects = map(&[(
"/Users/x/proj/.claude/worktrees/witty-curie/checkout",
json!({}),
)]);
let orphans = find(&projects, false);
assert_eq!(orphans.len(), 1);
assert!(orphans[0].is_worktree);
}
#[test]
fn worktrees_only_filters_non_worktree() {
let projects = map(&[
("/no/such/path", json!({})),
(
"/Users/x/proj/.claude/worktrees/witty-curie/checkout",
json!({}),
),
]);
let orphans = find(&projects, true);
assert_eq!(orphans.len(), 1);
assert!(orphans[0].is_worktree);
}
#[test]
fn wrong_host_heuristic() {
assert!(!looks_like_wrong_host(3, 4), "small total never trips");
assert!(!looks_like_wrong_host(4, 10), "40% is normal");
assert!(!looks_like_wrong_host(0, 10), "nothing missing");
assert!(looks_like_wrong_host(9, 10), "90% missing");
assert!(looks_like_wrong_host(10, 10), "all missing");
}
#[test]
fn counts_split_worktree_and_other() {
let orphans = vec![
Orphan {
path: "/a".into(),
is_worktree: false,
},
Orphan {
path: "/b".into(),
is_worktree: true,
},
Orphan {
path: "/c".into(),
is_worktree: true,
},
];
assert_eq!(counts(&orphans), (2, 1));
}
}