use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub(crate) struct ProcessSnapshot {
#[allow(dead_code)]
pub pid: u32,
pub cwd: Option<PathBuf>,
pub txt_paths: Vec<PathBuf>,
pub claude_fd_paths: Vec<PathBuf>,
}
pub(crate) fn is_claude_process(snap: &ProcessSnapshot) -> bool {
snap.txt_paths.iter().any(|p| {
let s = p.to_string_lossy();
s.contains("/.local/share/claude/versions/") || s.contains("/Claude.app/")
})
}
pub(crate) fn process_holds_worktree(snap: &ProcessSnapshot, worktree_canon: &Path) -> bool {
if let Some(cwd) = &snap.cwd {
if cwd == worktree_canon || cwd.starts_with(worktree_canon) {
return true;
}
}
let dot_claude = worktree_canon.join(".claude");
snap.claude_fd_paths
.iter()
.any(|p| p == &dot_claude || p.starts_with(&dot_claude))
}
static SNAPSHOT_CACHE: OnceLock<Vec<ProcessSnapshot>> = OnceLock::new();
fn snapshot() -> &'static [ProcessSnapshot] {
SNAPSHOT_CACHE.get_or_init(scan_processes).as_slice()
}
pub(crate) fn prewarm() {
let _ = snapshot();
}
pub fn has_live_claude_in(worktree: &Path) -> bool {
let canon = worktree
.canonicalize()
.unwrap_or_else(|_| worktree.to_path_buf());
snapshot()
.iter()
.any(|s| is_claude_process(s) && process_holds_worktree(s, &canon))
}
#[cfg(target_os = "macos")]
fn scan_processes() -> Vec<ProcessSnapshot> {
use std::collections::HashMap;
use std::process::Command;
let ps_out = match Command::new("ps").args(["-Ao", "pid=,comm="]).output() {
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
let mut candidate_pids: Vec<u32> = Vec::new();
for line in String::from_utf8_lossy(&ps_out.stdout).lines() {
let line = line.trim_start();
let (pid_s, rest) = match line.split_once(' ') {
Some(p) => p,
None => continue,
};
let Ok(pid) = pid_s.parse::<u32>() else {
continue;
};
let basename = std::path::Path::new(rest.trim())
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
if basename == "claude" || basename == "Claude" {
candidate_pids.push(pid);
}
}
if candidate_pids.is_empty() {
return Vec::new();
}
let pid_arg = candidate_pids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let output = match Command::new("lsof")
.args(["-F", "pfn", "+c", "0", "-p", &pid_arg])
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
if !output.status.success() && output.stdout.is_empty() {
return Vec::new();
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut by_pid: HashMap<u32, ProcessSnapshot> = HashMap::new();
let mut cur_pid: Option<u32> = None;
let mut cur_fd = String::new();
for line in stdout.lines() {
if let Some(rest) = line.strip_prefix('p') {
cur_pid = rest.parse().ok();
cur_fd.clear();
} else if let Some(rest) = line.strip_prefix('f') {
cur_fd = rest.to_string();
} else if let Some(rest) = line.strip_prefix('n') {
let Some(pid) = cur_pid else { continue };
let path = PathBuf::from(rest);
let entry = by_pid.entry(pid).or_insert_with(|| ProcessSnapshot {
pid,
cwd: None,
txt_paths: Vec::new(),
claude_fd_paths: Vec::new(),
});
match cur_fd.as_str() {
"cwd" => {
let canon = path.canonicalize().unwrap_or(path);
entry.cwd = Some(canon);
}
"txt" => entry.txt_paths.push(path),
_ => {
if path.to_string_lossy().contains("/.claude") {
let canon = path.canonicalize().unwrap_or(path);
entry.claude_fd_paths.push(canon);
}
}
}
}
}
by_pid.into_values().collect()
}
#[cfg(target_os = "linux")]
fn scan_processes() -> Vec<ProcessSnapshot> {
let proc_dir = match std::fs::read_dir("/proc") {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for entry in proc_dir.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let pid: u32 = match name.parse() {
Ok(n) => n,
Err(_) => continue,
};
let proc_path = entry.path();
let comm = match std::fs::read_to_string(proc_path.join("comm")) {
Ok(s) => s.trim().to_string(),
Err(_) => continue,
};
if comm != "claude" && comm != "Claude" {
continue;
}
let cwd = std::fs::read_link(proc_path.join("cwd"))
.ok()
.map(|p| p.canonicalize().unwrap_or(p));
let mut txt_paths = Vec::new();
if let Ok(exe) = std::fs::read_link(proc_path.join("exe")) {
txt_paths.push(exe);
}
let mut claude_fd_paths = Vec::new();
if let Ok(fds) = std::fs::read_dir(proc_path.join("fd")) {
for fd_entry in fds.flatten() {
if let Ok(target) = std::fs::read_link(fd_entry.path()) {
if target.to_string_lossy().contains("/.claude") {
let canon = target.canonicalize().unwrap_or(target);
claude_fd_paths.push(canon);
}
}
}
}
out.push(ProcessSnapshot {
pid,
cwd,
txt_paths,
claude_fd_paths,
});
}
out
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn scan_processes() -> Vec<ProcessSnapshot> {
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(pid: u32, cwd: Option<&str>, txt: &[&str], fds: &[&str]) -> ProcessSnapshot {
ProcessSnapshot {
pid,
cwd: cwd.map(PathBuf::from),
txt_paths: txt.iter().map(PathBuf::from).collect(),
claude_fd_paths: fds.iter().map(PathBuf::from).collect(),
}
}
#[test]
fn is_claude_process_detects_user_install() {
let s = snap(
1,
Some("/wt"),
&["/Users/dave/.local/share/claude/versions/2.1.121"],
&[],
);
assert!(is_claude_process(&s));
}
#[test]
fn is_claude_process_detects_macos_desktop_app() {
let s = snap(
1,
Some("/wt"),
&["/Applications/Claude.app/Contents/MacOS/Claude"],
&[],
);
assert!(is_claude_process(&s));
}
#[test]
fn is_claude_process_rejects_unrelated_binary() {
let s = snap(1, Some("/wt"), &["/usr/bin/zsh"], &[]);
assert!(!is_claude_process(&s));
}
#[test]
fn is_claude_process_rejects_substring_outside_claude_path() {
let s = snap(1, Some("/wt"), &["/etc/claude/foo"], &[]);
assert!(!is_claude_process(&s));
}
#[test]
fn process_holds_worktree_matches_exact_cwd() {
let s = snap(1, Some("/wt"), &[], &[]);
assert!(process_holds_worktree(&s, Path::new("/wt")));
}
#[test]
fn process_holds_worktree_matches_descendant_cwd() {
let s = snap(1, Some("/wt/subdir/inner"), &[], &[]);
assert!(process_holds_worktree(&s, Path::new("/wt")));
}
#[test]
fn process_holds_worktree_matches_dot_claude_fd() {
let s = snap(1, Some("/elsewhere"), &[], &["/wt/.claude/settings.json"]);
assert!(process_holds_worktree(&s, Path::new("/wt")));
}
#[test]
fn process_holds_worktree_rejects_unrelated_cwd_and_fds() {
let s = snap(
1,
Some("/elsewhere"),
&[],
&["/Users/dave/.claude/settings.json"],
);
assert!(!process_holds_worktree(&s, Path::new("/wt")));
}
#[test]
fn process_holds_worktree_rejects_sibling_with_shared_prefix() {
let s = snap(1, Some("/wt-other/sub"), &[], &[]);
assert!(!process_holds_worktree(&s, Path::new("/wt")));
}
}