use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use crate::git_env::git_command as git_in;
pub const ORPHAN_MIN_AGE: Duration = Duration::from_secs(60);
pub fn remove_audit_worktree(repo_root: &Path, path: &Path) {
let mut command = git_in(repo_root);
command.args([
"worktree",
"remove",
"--force",
path.to_string_lossy().as_ref(),
]);
match fallow_process::output(&mut command) {
Ok(output) => {
if !output.status.success() && path.exists() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
path = %path.display(),
stderr = %stderr.trim(),
"git worktree remove failed; the directory remains and may leak",
);
}
}
Err(err) => {
tracing::warn!(
path = %path.display(),
error = %err,
"git worktree remove subprocess failed to spawn",
);
}
}
}
pub fn sweep_orphan_audit_worktrees(repo_root: &Path) {
sweep_orphan_audit_worktrees_in(repo_root, &std::env::temp_dir(), ORPHAN_MIN_AGE);
}
pub fn sweep_orphan_audit_worktrees_in(repo_root: &Path, temp_root: &Path, min_age: Duration) {
if has_dead_owner_registration(repo_root) {
deregister_legacy_orphan_worktrees(repo_root, min_age);
}
for path in scan_non_reusable_orphan_paths(temp_root, min_age) {
let _ = fs::remove_dir_all(&path);
}
}
fn deregister_legacy_orphan_worktrees(repo_root: &Path, min_age: Duration) {
let Some(worktrees) = list_audit_worktrees(repo_root) else {
return;
};
for path in worktrees {
if !is_fallow_audit_worktree_path(&path)
|| is_reusable_audit_worktree_path(&path)
|| audit_worktree_process_is_alive(&path)
|| (path.exists() && !is_older_than(&path, min_age))
{
continue;
}
remove_audit_worktree(repo_root, &path);
let _ = fs::remove_dir_all(&path);
}
}
fn has_dead_owner_registration(repo_root: &Path) -> bool {
let Some(admin) = worktrees_admin_dir(repo_root) else {
return true;
};
let Ok(entries) = fs::read_dir(admin) else {
return false;
};
entries.flatten().any(|entry| {
entry
.file_name()
.to_str()
.and_then(audit_worktree_pid)
.is_some_and(|pid| !process_is_alive(pid))
})
}
fn worktrees_admin_dir(dir: &Path) -> Option<PathBuf> {
let dir = std::path::absolute(dir).ok()?;
for candidate in dir.ancestors() {
let dotgit = candidate.join(".git");
let Ok(metadata) = fs::symlink_metadata(&dotgit) else {
continue;
};
if metadata.is_dir() {
return Some(dotgit.join("worktrees"));
}
let contents = fs::read_to_string(&dotgit).ok()?;
let gitdir = candidate.join(crate::repo_refs::parse_worktree_gitdir(&contents)?);
let common = fs::read_to_string(gitdir.join("commondir"))
.map_or_else(|_| gitdir.clone(), |common| gitdir.join(common.trim()));
return Some(common.join("worktrees"));
}
None
}
fn is_older_than(path: &Path, min_age: Duration) -> bool {
fs::symlink_metadata(path)
.and_then(|metadata| metadata.modified())
.is_ok_and(|modified| {
SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::ZERO)
>= min_age
})
}
fn scan_non_reusable_orphan_paths(temp: &Path, min_age: Duration) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(temp) else {
return Vec::new();
};
let mut paths = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Some(pid) = audit_worktree_pid(name) else {
continue;
};
let path = temp.join(name);
if !path.is_dir() || process_is_alive(pid) || !is_older_than(&path, min_age) {
continue;
}
paths.push(path);
}
paths
}
pub fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
let output = git_in(repo_root)
.args(["worktree", "list", "--porcelain"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(parse_worktree_list(&String::from_utf8_lossy(
&output.stdout,
)))
}
#[must_use]
pub fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
output
.lines()
.filter_map(|line| line.strip_prefix("worktree "))
.map(PathBuf::from)
.filter(|path| is_fallow_audit_worktree_path(path))
.collect()
}
#[must_use]
pub fn is_fallow_audit_worktree_path(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
}
#[must_use]
pub fn is_reusable_audit_worktree_path(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
}
fn path_is_inside_temp_dir(path: &Path) -> bool {
let temp = std::env::temp_dir();
let simple_path = dunce::simplified(path);
let simple_temp = dunce::simplified(&temp);
if simple_path.starts_with(simple_temp) {
return true;
}
let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
return false;
};
let simple_canonical_temp = dunce::simplified(&canonical_temp);
simple_path.starts_with(simple_canonical_temp)
|| std::fs::canonicalize(path).is_ok_and(|canonical_path| {
dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
})
}
fn audit_worktree_process_is_alive(path: &Path) -> bool {
let Some(pid) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(audit_worktree_pid)
else {
return false;
};
process_is_alive(pid)
}
#[must_use]
pub fn audit_worktree_pid(name: &str) -> Option<u32> {
name.strip_prefix("fallow-audit-base-")?
.split('-')
.next()?
.parse()
.ok()
}
#[cfg(unix)]
#[must_use]
#[allow(
unsafe_code,
reason = "kill(2) with signal 0 is the liveness probe; it has no memory preconditions"
)]
pub fn process_is_alive(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return true;
};
if pid <= 0 {
return true;
}
let result = unsafe { libc::kill(pid, 0) };
result == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
}
#[cfg(windows)]
#[must_use]
pub fn process_is_alive(pid: u32) -> bool {
windows_process::is_alive(pid)
}
#[cfg(not(any(unix, windows)))]
#[must_use]
pub fn process_is_alive(_pid: u32) -> bool {
true
}
#[cfg(windows)]
#[allow(
unsafe_code,
reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
)]
mod windows_process {
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
WAIT_OBJECT_0,
};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
};
struct ProcessHandle(HANDLE);
impl Drop for ProcessHandle {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
pub fn is_alive(pid: u32) -> bool {
let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if raw.is_null() {
let err = unsafe { GetLastError() };
#[expect(
clippy::match_same_arms,
reason = "named arm documents the cross-session case"
)]
return match err {
ERROR_INVALID_PARAMETER => false,
ERROR_ACCESS_DENIED => true,
_ => true,
};
}
let handle = ProcessHandle(raw);
let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
wait_result != WAIT_OBJECT_0
}
}
#[cfg(test)]
mod tests {
use super::*;
const DEAD_PID: u32 = 99_999_998;
#[cfg(unix)]
fn snapshot_dir(root: &Path, pid: u32, age: Duration) -> PathBuf {
let path = root.join(format!("fallow-audit-base-{pid}-1-0"));
fs::create_dir_all(&path).expect("create snapshot dir");
fs::write(path.join("index.ts"), "").expect("write snapshot file");
let modified = SystemTime::now() - age;
fs::File::open(&path)
.and_then(|dir| dir.set_modified(modified))
.expect("set snapshot time");
path
}
#[cfg(unix)]
#[test]
fn liveness_counts_only_esrch_as_dead() {
assert!(process_is_alive(std::process::id()));
assert!(!process_is_alive(DEAD_PID));
assert!(process_is_alive(1), "an uninspectable process is alive");
assert!(process_is_alive(0), "process group ids are never probed");
assert!(process_is_alive(u32::MAX));
}
#[cfg(unix)]
#[test]
fn cleanup_removes_only_old_snapshots_of_dead_owners() {
let temp = tempfile::tempdir().expect("temp dir");
let old = ORPHAN_MIN_AGE + Duration::from_secs(60);
let live = snapshot_dir(temp.path(), std::process::id(), old);
let dead = temp.path().join("dead");
fs::create_dir_all(&dead).expect("create dead parent");
let dead_old = snapshot_dir(&dead, DEAD_PID, old);
let young = temp.path().join("young");
fs::create_dir_all(&young).expect("create young parent");
let dead_young = snapshot_dir(&young, DEAD_PID, Duration::ZERO);
let not_a_repo = temp.path().join("not-a-repo");
for root in [temp.path(), dead.as_path(), young.as_path()] {
sweep_orphan_audit_worktrees_in(¬_a_repo, root, ORPHAN_MIN_AGE);
}
assert!(live.is_dir(), "the snapshot of a live owner stays");
assert!(!dead_old.exists(), "an old snapshot of a dead owner goes");
assert!(
dead_young.is_dir(),
"a snapshot younger than the floor stays"
);
}
#[test]
fn registration_check_reads_the_admin_dir_without_git() {
let temp = tempfile::tempdir().expect("temp dir");
let repo = temp.path().join("repo");
let admin = repo.join(".git").join("worktrees");
fs::create_dir_all(&admin).expect("create admin dir");
assert!(!has_dead_owner_registration(&repo));
fs::create_dir_all(admin.join("fallow-audit-base-cache-0-root-0"))
.expect("create reusable admin entry");
fs::create_dir_all(admin.join(format!("fallow-audit-base-{}-1-0", std::process::id())))
.expect("create live admin entry");
assert!(!has_dead_owner_registration(&repo.join("nested")));
fs::create_dir_all(admin.join(format!("fallow-audit-base-{DEAD_PID}-1-0")))
.expect("create dead admin entry");
assert!(has_dead_owner_registration(&repo.join("nested")));
}
}