use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
use std::process::Command;
use std::sync::OnceLock;
use super::{claude_session, lockfile};
use chrono::Duration as ChronoDuration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BusyTier {
Hard,
Soft,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BusySource {
Lockfile,
ClaudeSession,
ProcessScan,
}
#[derive(Debug, Clone)]
pub struct BusyInfo {
pub pid: u32,
pub cmd: String,
pub cwd: PathBuf,
pub source: BusySource,
pub tier: BusyTier,
pub tty: Option<bool>,
pub started_secs_ago: Option<u64>,
}
static SELF_TREE: OnceLock<HashSet<u32>> = OnceLock::new();
static SELF_SIBLINGS: 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(unix)]
fn compute_self_siblings() -> HashSet<u32> {
let mut siblings = HashSet::new();
let our_pid = std::process::id();
let our_pgid = unsafe { libc::getpgrp() } as u32;
if our_pgid == 0 || our_pgid == 1 {
return siblings;
}
let parent_pid = unsafe { libc::getppid() } as u32;
if parent_pid == 0 {
return siblings;
}
let parent_pgid = pgid_of(parent_pid).unwrap_or(0);
if parent_pgid == our_pgid {
return siblings;
}
for (pid, _, _) in cwd_scan() {
if *pid == our_pid {
continue;
}
if let Some(pgid) = pgid_of(*pid) {
if pgid == our_pgid {
siblings.insert(*pid);
}
}
}
siblings
}
#[cfg(not(unix))]
fn compute_self_siblings() -> HashSet<u32> {
HashSet::new()
}
#[cfg(target_os = "linux")]
fn pgid_of(pid: u32) -> Option<u32> {
let status = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
let after_comm = status.rsplit_once(')')?.1;
let fields: Vec<&str> = after_comm.split_whitespace().collect();
fields.get(2)?.parse().ok()
}
#[cfg(target_os = "macos")]
fn pgid_of(pid: u32) -> Option<u32> {
let out = Command::new("ps")
.args(["-o", "pgid=", "-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 pgid_of(_pid: u32) -> Option<u32> {
None
}
pub fn self_siblings() -> &'static HashSet<u32> {
SELF_SIBLINGS.get_or_init(compute_self_siblings)
}
#[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_attr(not(any(target_os = "macos", test)), allow(dead_code))]
fn is_suspicious_cmd(cmd: &str) -> bool {
if cmd.is_empty() {
return true;
}
let mut chars = cmd.chars();
let first = chars.next().unwrap();
let starts_ok = first == 'v' || first.is_ascii_digit();
if !starts_ok {
return false;
}
let mut seen_digit = first.is_ascii_digit();
for c in chars {
if c.is_ascii_digit() {
seen_digit = true;
} else if c != '.' {
return false;
}
}
seen_digit
}
#[cfg(target_os = "macos")]
fn kernel_comm(pid: u32) -> Option<String> {
let out = Command::new("ps")
.args(["-o", "comm=", "-p", &pid.to_string()])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
if raw.is_empty() {
return None;
}
let base = std::path::Path::new(&raw)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or(raw);
Some(base)
}
#[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());
let cmd = if is_suspicious_cmd(&cur_cmd) {
kernel_comm(pid).unwrap_or_else(|| cur_cmd.clone())
} else {
cur_cmd.clone()
};
out.push((pid, cmd, 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_tree = self_process_tree();
let exclude_siblings = self_siblings();
let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
let mut out = Vec::new();
if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
if !is_excluded(entry.pid) {
out.push(BusyInfo {
pid: entry.pid,
cmd: entry.cmd,
cwd: worktree.to_path_buf(),
source: BusySource::Lockfile,
tier: BusyTier::Hard,
tty: None,
started_secs_ago: None,
});
}
}
for info in scan_cwd(worktree) {
if is_excluded(info.pid) {
continue;
}
if out.iter().any(|b| b.pid == info.pid) {
continue;
}
out.push(info);
}
out
}
pub fn detect_busy_lockfile_only(worktree: &Path) -> Vec<BusyInfo> {
let exclude_tree = self_process_tree();
let is_excluded = |pid: u32| exclude_tree.contains(&pid);
let mut out = Vec::new();
if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
if !is_excluded(entry.pid) {
out.push(BusyInfo {
pid: entry.pid,
cmd: entry.cmd,
cwd: worktree.to_path_buf(),
source: BusySource::Lockfile,
tier: BusyTier::Hard,
tty: None,
started_secs_ago: None,
});
}
}
out
}
const CLAUDE_ACTIVITY_THRESHOLD_MIN: i64 = 10;
pub fn detect_busy_tiered(worktree: &Path) -> (Vec<BusyInfo>, Vec<BusyInfo>) {
let exclude_tree = self_process_tree();
let exclude_siblings = self_siblings();
let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
let mut hard = Vec::new();
if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
if !is_excluded(entry.pid) {
hard.push(BusyInfo {
pid: entry.pid,
cmd: entry.cmd,
cwd: worktree.to_path_buf(),
source: BusySource::Lockfile,
tier: BusyTier::Hard,
tty: None,
started_secs_ago: None,
});
}
}
if let Some(proj_dir) = claude_session::project_dir_for(worktree) {
let threshold = ChronoDuration::minutes(CLAUDE_ACTIVITY_THRESHOLD_MIN);
for s in claude_session::find_active_sessions(&proj_dir, worktree, threshold) {
let secs_ago = (chrono::Utc::now() - s.last_activity).num_seconds().max(0) as u64;
hard.push(BusyInfo {
pid: 0,
cmd: format!("claude (session {})", s.session_id),
cwd: worktree.to_path_buf(),
source: BusySource::ClaudeSession,
tier: BusyTier::Hard,
tty: None,
started_secs_ago: Some(secs_ago),
});
}
}
let mut soft = Vec::new();
for info in scan_cwd(worktree) {
if is_excluded(info.pid) {
continue;
}
if hard.iter().any(|b| b.pid == info.pid && b.pid != 0) {
continue;
}
soft.push(info);
}
(hard, soft)
}
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,
tier: BusyTier::Soft,
tty: None,
started_secs_ago: None,
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_suspicious_cmd_flags_version_strings() {
assert!(is_suspicious_cmd(""));
assert!(is_suspicious_cmd("2.1.104"));
assert!(is_suspicious_cmd("0.0.1"));
assert!(is_suspicious_cmd("v1.2.3"));
assert!(is_suspicious_cmd("42"));
}
#[test]
fn is_suspicious_cmd_accepts_real_names() {
assert!(!is_suspicious_cmd("claude"));
assert!(!is_suspicious_cmd("node"));
assert!(!is_suspicious_cmd("zsh"));
assert!(!is_suspicious_cmd("tmux: server"));
assert!(!is_suspicious_cmd("python3"));
assert!(!is_suspicious_cmd("v"));
assert!(!is_suspicious_cmd("vim"));
}
#[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
);
}
#[test]
fn detect_busy_tiered_returns_hard_for_lockfile() {
use std::process::{Command, Stdio};
let dir = tempfile::tempdir().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
let mut child = Command::new("sleep")
.arg("30")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep");
let child_pid = child.id();
let entry = crate::operations::lockfile::LockEntry {
version: crate::operations::lockfile::LOCK_VERSION,
pid: child_pid,
started_at: 0,
cmd: "claude".to_string(),
};
std::fs::write(
git_dir.join("gw-session.lock"),
serde_json::to_string(&entry).unwrap(),
)
.unwrap();
let (hard, _soft) = detect_busy_tiered(dir.path());
let _ = child.kill();
let _ = child.wait();
assert!(hard
.iter()
.any(|b| matches!(b.source, BusySource::Lockfile)));
assert!(hard.iter().all(|b| matches!(b.tier, BusyTier::Hard)));
}
#[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()
);
}
}