use std::path::Path;
use crate::session_manager::decommission::is_session_worktree;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceLiveness {
Live,
Absent,
Gutted,
Indeterminate(String),
}
impl WorkspaceLiveness {
pub fn is_dead(&self) -> bool {
matches!(self, Self::Absent | Self::Gutted)
}
}
impl std::fmt::Display for WorkspaceLiveness {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Live => f.write_str("live"),
Self::Absent => f.write_str("no longer exists on disk"),
Self::Gutted => f.write_str(
"gutted — the directory survives but its git checkout (.git) has been stripped",
),
Self::Indeterminate(reason) => {
write!(
f,
"liveness could not be determined ({reason}) — treated as live"
)
}
}
}
}
pub fn workspace_liveness(path: &Path, workspace_owned: bool) -> WorkspaceLiveness {
match std::fs::metadata(path) {
Ok(md) if md.is_dir() => {}
Ok(_) => return WorkspaceLiveness::Absent,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return WorkspaceLiveness::Absent,
Err(e) => return WorkspaceLiveness::Indeterminate(format!("cannot stat workspace: {e}")),
}
if !(workspace_owned || is_session_worktree(path)) {
return WorkspaceLiveness::Live;
}
match std::fs::symlink_metadata(path.join(".git")) {
Ok(_) => WorkspaceLiveness::Live,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => WorkspaceLiveness::Gutted,
Err(e) => WorkspaceLiveness::Indeterminate(format!("cannot stat .git: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn worktree_shaped(base: &TempDir, leaf: &str) -> std::path::PathBuf {
let p = base
.path()
.join(crate::session_manager::decommission::worktrees_dirname())
.join(leaf);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn liveness_accepts_linked_worktree_git_file() {
let base = TempDir::new().unwrap();
let wt = worktree_shaped(&base, "f443c12d-2fb6-4ce1-9f70-2e7695306e47");
fs::write(
wt.join(".git"),
"gitdir: /repo/.base/.git/worktrees/f443c12d\n",
)
.unwrap();
let liveness = workspace_liveness(&wt, true);
assert_eq!(liveness, WorkspaceLiveness::Live, "{liveness}");
assert!(!liveness.is_dead());
assert!(
!wt.join(".git").is_dir(),
"fixture must model a .git FILE, not a directory — otherwise this \
test cannot catch an is_dir()-shaped guard"
);
}
#[test]
fn liveness_accepts_clone_git_directory() {
let base = TempDir::new().unwrap();
let clone = base.path().join("checkout");
fs::create_dir_all(clone.join(".git")).unwrap();
assert_eq!(workspace_liveness(&clone, true), WorkspaceLiveness::Live);
}
#[test]
fn liveness_reports_gutted_worktree_missing_git() {
let base = TempDir::new().unwrap();
let wt = worktree_shaped(&base, "f443c12d-2fb6-4ce1-9f70-2e7695306e47");
fs::create_dir_all(wt.join(".claude").join("agents")).unwrap();
assert!(wt.is_dir(), "the old is_dir() gate would have passed this");
let liveness = workspace_liveness(&wt, true);
assert_eq!(liveness, WorkspaceLiveness::Gutted);
assert!(liveness.is_dead());
}
#[test]
fn liveness_never_requires_git_for_unowned_non_worktree_path() {
let plain = TempDir::new().unwrap();
assert!(!plain.path().join(".git").exists());
let liveness = workspace_liveness(plain.path(), false);
assert_eq!(liveness, WorkspaceLiveness::Live, "{liveness}");
assert!(!liveness.is_dead());
}
#[test]
fn liveness_reports_absent_directory() {
let base = TempDir::new().unwrap();
let gone = base.path().join("never-existed");
assert_eq!(
std::fs::metadata(&gone).err().map(|e| e.kind()),
Some(std::io::ErrorKind::NotFound),
"test premise: the path must be absent, not merely unresolvable"
);
let liveness = workspace_liveness(&gone, true);
assert_eq!(liveness, WorkspaceLiveness::Absent);
assert!(liveness.is_dead());
}
#[cfg(unix)]
#[test]
fn liveness_symlink_loop_is_indeterminate_never_dead() {
let base = TempDir::new().unwrap();
let a = base.path().join("a");
let b = base.path().join("b");
std::os::unix::fs::symlink(&b, &a).expect("symlink a -> b");
std::os::unix::fs::symlink(&a, &b).expect("symlink b -> a");
let kind = std::fs::metadata(&a)
.expect_err("test premise: a symlink loop must not stat")
.kind();
assert_ne!(
kind,
std::io::ErrorKind::NotFound,
"test premise: the loop must produce a non-NotFound error kind"
);
let liveness = workspace_liveness(&a, true);
assert!(
!liveness.is_dead(),
"an ambiguous stat failure ({kind:?}) must never be treated as \
death; got {liveness:?}"
);
assert!(
matches!(liveness, WorkspaceLiveness::Indeterminate(_)),
"got {liveness:?}"
);
}
#[cfg(unix)]
#[test]
fn liveness_unreadable_parent_is_indeterminate_never_dead() {
use std::os::unix::fs::PermissionsExt;
let base = TempDir::new().unwrap();
let wt = worktree_shaped(&base, "f443c12d-2fb6-4ce1-9f70-2e7695306e47");
fs::write(
wt.join(".git"),
"gitdir: /repo/.base/.git/worktrees/f443c12d\n",
)
.unwrap();
let parent = wt.parent().unwrap().to_path_buf();
let restore = fs::metadata(&parent).unwrap().permissions();
fs::set_permissions(&parent, fs::Permissions::from_mode(0o000)).unwrap();
let staged = fs::metadata(&wt).is_err();
let liveness = workspace_liveness(&wt, true);
fs::set_permissions(&parent, restore).unwrap();
if !staged {
eprintln!("skipped: running privileged, EACCES could not be staged");
return;
}
assert!(
!liveness.is_dead(),
"a LIVE worktree that merely could not be stat'd must never be \
treated as dead — got {liveness:?}"
);
assert!(
matches!(liveness, WorkspaceLiveness::Indeterminate(_)),
"an unreadable path is an absence of evidence, not evidence of \
absence — got {liveness:?}"
);
assert_eq!(workspace_liveness(&wt, true), WorkspaceLiveness::Live);
}
#[test]
fn liveness_reports_non_directory_as_absent() {
let base = TempDir::new().unwrap();
let file = base.path().join("not-a-dir");
fs::write(&file, "x").unwrap();
let liveness = workspace_liveness(&file, true);
assert_eq!(liveness, WorkspaceLiveness::Absent);
assert!(liveness.is_dead());
}
#[test]
fn liveness_indeterminate_is_never_dead() {
let unknown = WorkspaceLiveness::Indeterminate("permission denied".into());
assert!(
!unknown.is_dead(),
"an ambiguous observation must never be treated as death"
);
assert!(unknown.to_string().contains("treated as live"));
}
}