use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
use std::process::Command;
use std::sync::OnceLock;
use super::lockfile;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BusySource {
Lockfile,
ProcessScan,
}
#[derive(Debug, Clone)]
pub struct BusyInfo {
pub pid: u32,
pub cmd: String,
pub cwd: PathBuf,
pub source: BusySource,
}
static SELF_TREE: OnceLock<HashSet<u32>> = OnceLock::new();
static CWD_SCAN_CACHE: OnceLock<Vec<(u32, String, PathBuf)>> = OnceLock::new();
static SCAN_WARNING: OnceLock<()> = OnceLock::new();
fn compute_self_tree() -> HashSet<u32> {
let mut tree = HashSet::new();
tree.insert(std::process::id());
#[cfg(unix)]
{
let mut pid = unsafe { libc::getppid() } as u32;
for _ in 0..64 {
if pid == 0 {
break;
}
if pid == 1 {
tree.insert(pid);
break;
}
tree.insert(pid);
match parent_of(pid) {
Some(ppid) if ppid != pid => pid = ppid,
_ => break,
}
}
}
tree
}
pub fn self_process_tree() -> &'static HashSet<u32> {
SELF_TREE.get_or_init(compute_self_tree)
}
#[cfg(target_os = "linux")]
fn parent_of(pid: u32) -> Option<u32> {
let status = std::fs::read_to_string(format!("/proc/{}/status", pid)).ok()?;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("PPid:") {
return rest.trim().parse().ok();
}
}
None
}
#[cfg(target_os = "macos")]
fn parent_of(pid: u32) -> Option<u32> {
let out = Command::new("ps")
.args(["-o", "ppid=", "-p", &pid.to_string()])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
#[allow(dead_code)]
fn parent_of(_pid: u32) -> Option<u32> {
None
}
#[allow(dead_code)]
fn warn_scan_failed(what: &str) {
if SCAN_WARNING.set(()).is_ok() {
eprintln!(
"{} could not scan processes: {}",
console::style("warning:").yellow(),
what
);
}
}
fn cwd_scan() -> &'static [(u32, String, PathBuf)] {
CWD_SCAN_CACHE.get_or_init(raw_cwd_scan).as_slice()
}
#[cfg(target_os = "linux")]
fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
let mut out = Vec::new();
let proc_dir = match std::fs::read_dir("/proc") {
Ok(d) => d,
Err(e) => {
warn_scan_failed(&format!("/proc unreadable: {}", e));
return out;
}
};
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 cwd_link = entry.path().join("cwd");
let cwd = match std::fs::read_link(&cwd_link) {
Ok(p) => p,
Err(_) => continue,
};
let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
let cmd = std::fs::read_to_string(entry.path().join("comm"))
.map(|s| s.trim().to_string())
.unwrap_or_default();
out.push((pid, cmd, cwd_canon));
}
out
}
#[cfg(target_os = "macos")]
fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
let mut out = Vec::new();
let output = match Command::new("lsof")
.args(["-a", "-d", "cwd", "-F", "pcn", "+c", "0"])
.output()
{
Ok(o) => o,
Err(e) => {
warn_scan_failed(&format!("lsof unavailable: {}", e));
return out;
}
};
if !output.status.success() && output.stdout.is_empty() {
warn_scan_failed("lsof returned no output");
return out;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut cur_pid: Option<u32> = None;
let mut cur_cmd = String::new();
for line in stdout.lines() {
if let Some(rest) = line.strip_prefix('p') {
cur_pid = rest.parse().ok();
cur_cmd.clear();
} else if let Some(rest) = line.strip_prefix('c') {
cur_cmd = rest.to_string();
} else if let Some(rest) = line.strip_prefix('n') {
if let Some(pid) = cur_pid {
let cwd = PathBuf::from(rest);
let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
out.push((pid, cur_cmd.clone(), cwd_canon));
}
}
}
out
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
Vec::new()
}
pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
let exclude = self_process_tree();
let mut out = Vec::new();
if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
if !exclude.contains(&entry.pid) {
out.push(BusyInfo {
pid: entry.pid,
cmd: entry.cmd,
cwd: worktree.to_path_buf(),
source: BusySource::Lockfile,
});
}
}
for info in scan_cwd(worktree) {
if exclude.contains(&info.pid) {
continue;
}
if out.iter().any(|b| b.pid == info.pid) {
continue;
}
out.push(info);
}
out
}
fn is_multiplexer(cmd: &str) -> bool {
matches!(
cmd,
"zellij" | "tmux" | "tmux: server" | "tmate" | "tmate: server" | "screen" | "SCREEN"
)
}
fn scan_cwd(worktree: &Path) -> Vec<BusyInfo> {
let canon_target = match worktree.canonicalize() {
Ok(p) => p,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for (pid, cmd, cwd) in cwd_scan() {
if cwd.starts_with(&canon_target) {
if is_multiplexer(cmd) {
continue;
}
out.push(BusyInfo {
pid: *pid,
cmd: cmd.clone(),
cwd: cwd.clone(),
source: BusySource::ProcessScan,
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_multiplexer_matches_known_names() {
for name in [
"zellij",
"tmux",
"tmux: server",
"tmate",
"tmate: server",
"screen",
"SCREEN",
] {
assert!(is_multiplexer(name), "expected match for {:?}", name);
}
}
#[test]
fn is_multiplexer_rejects_non_multiplexers() {
for name in [
"",
"zsh",
"bash",
"claude",
"tmuxinator",
"ztmux",
"zellij-server",
"Screen",
] {
assert!(!is_multiplexer(name), "expected no match for {:?}", name);
}
}
#[test]
fn self_tree_contains_current_pid() {
let tree = self_process_tree();
assert!(tree.contains(&std::process::id()));
}
#[cfg(unix)]
#[test]
fn self_tree_contains_parent_pid() {
let tree = self_process_tree();
let ppid = unsafe { libc::getppid() } as u32;
assert!(
tree.contains(&ppid),
"expected tree to contain ppid {}",
ppid
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn scan_cwd_finds_child_with_cwd_in_tempdir() {
use std::process::{Command, Stdio};
use std::thread::sleep;
use std::time::{Duration, Instant};
let dir = tempfile::TempDir::new().unwrap();
let mut child = Command::new("sleep")
.arg("30")
.current_dir(dir.path())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep");
sleep(Duration::from_millis(50));
let canon = dir
.path()
.canonicalize()
.unwrap_or(dir.path().to_path_buf());
let matches = |raw: &[(u32, String, std::path::PathBuf)]| -> bool {
raw.iter()
.any(|(p, _, cwd)| *p == child.id() && cwd.starts_with(&canon))
};
let mut found = matches(&raw_cwd_scan());
if !found {
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
if matches(&raw_cwd_scan()) {
found = true;
break;
}
sleep(Duration::from_millis(50));
}
}
let _ = child.kill();
let _ = child.wait();
assert!(
found,
"expected to find child pid={} with cwd in {:?}",
child.id(),
dir.path()
);
}
}