1use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13#[cfg(target_os = "macos")]
14use std::process::Command;
15use std::sync::OnceLock;
16
17use super::{claude_process, claude_session, lockfile};
18use chrono::Duration as ChronoDuration;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BusyTier {
26 Hard,
27 Soft,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum BusySource {
33 Lockfile,
34 ClaudeSession,
35 ProcessScan,
36}
37
38#[derive(Debug, Clone)]
40pub struct BusyInfo {
41 pub pid: u32,
42 pub cmd: String,
43 pub cwd: PathBuf,
47 pub source: BusySource,
48 pub tier: BusyTier,
49 pub tty: Option<bool>,
52 pub started_secs_ago: Option<u64>,
54}
55
56static SELF_TREE: OnceLock<HashSet<u32>> = OnceLock::new();
58
59static SELF_SIBLINGS: OnceLock<HashSet<u32>> = OnceLock::new();
64
65static CWD_SCAN_CACHE: OnceLock<Vec<(u32, String, PathBuf)>> = OnceLock::new();
68
69static SCAN_WARNING: OnceLock<()> = OnceLock::new();
73
74fn compute_self_tree() -> HashSet<u32> {
75 let mut tree = HashSet::new();
76 tree.insert(std::process::id());
77
78 #[cfg(unix)]
79 {
80 let mut pid = unsafe { libc::getppid() } as u32;
81 for _ in 0..64 {
82 if pid == 0 {
84 break;
85 }
86 if pid == 1 {
90 tree.insert(pid);
91 break;
92 }
93 tree.insert(pid);
94 match parent_of(pid) {
95 Some(ppid) if ppid != pid => pid = ppid,
96 _ => break,
97 }
98 }
99 }
100 tree
101}
102
103pub fn self_process_tree() -> &'static HashSet<u32> {
107 SELF_TREE.get_or_init(compute_self_tree)
108}
109
110#[cfg(unix)]
123fn compute_self_siblings() -> HashSet<u32> {
124 let mut siblings = HashSet::new();
125 let our_pid = std::process::id();
126 let our_pgid = unsafe { libc::getpgrp() } as u32;
127 if our_pgid == 0 || our_pgid == 1 {
128 return siblings;
129 }
130 let parent_pid = unsafe { libc::getppid() } as u32;
142 if parent_pid == 0 {
143 return siblings;
144 }
145 let parent_pgid = pgid_of(parent_pid).unwrap_or(0);
146 if parent_pgid == our_pgid {
147 return siblings;
148 }
149 for (pid, _, _) in cwd_scan() {
150 if *pid == our_pid {
151 continue;
152 }
153 if let Some(pgid) = pgid_of(*pid) {
154 if pgid == our_pgid {
155 siblings.insert(*pid);
156 }
157 }
158 }
159 siblings
160}
161
162#[cfg(not(unix))]
163fn compute_self_siblings() -> HashSet<u32> {
164 HashSet::new()
165}
166
167#[cfg(target_os = "linux")]
168fn pgid_of(pid: u32) -> Option<u32> {
169 let status = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
170 let after_comm = status.rsplit_once(')')?.1;
173 let fields: Vec<&str> = after_comm.split_whitespace().collect();
174 fields.get(2)?.parse().ok()
177}
178
179#[cfg(target_os = "macos")]
180fn pgid_of(pid: u32) -> Option<u32> {
181 let out = Command::new("ps")
182 .args(["-o", "pgid=", "-p", &pid.to_string()])
183 .output()
184 .ok()?;
185 if !out.status.success() {
186 return None;
187 }
188 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
189}
190
191#[cfg(not(any(target_os = "linux", target_os = "macos")))]
192#[allow(dead_code)]
193fn pgid_of(_pid: u32) -> Option<u32> {
194 None
195}
196
197pub fn self_siblings() -> &'static HashSet<u32> {
199 SELF_SIBLINGS.get_or_init(compute_self_siblings)
200}
201
202#[cfg(target_os = "linux")]
203fn parent_of(pid: u32) -> Option<u32> {
204 let status = std::fs::read_to_string(format!("/proc/{}/status", pid)).ok()?;
205 for line in status.lines() {
206 if let Some(rest) = line.strip_prefix("PPid:") {
207 return rest.trim().parse().ok();
208 }
209 }
210 None
211}
212
213#[cfg(target_os = "macos")]
214fn parent_of(pid: u32) -> Option<u32> {
215 let out = Command::new("ps")
216 .args(["-o", "ppid=", "-p", &pid.to_string()])
217 .output()
218 .ok()?;
219 if !out.status.success() {
220 return None;
221 }
222 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
223}
224
225#[cfg(not(any(target_os = "linux", target_os = "macos")))]
226#[allow(dead_code)]
227fn parent_of(_pid: u32) -> Option<u32> {
228 None
229}
230
231#[allow(dead_code)]
232fn warn_scan_failed(what: &str) {
233 if SCAN_WARNING.set(()).is_ok() {
234 eprintln!(
235 "{} could not scan processes: {}",
236 console::style("warning:").yellow(),
237 what
238 );
239 }
240}
241
242fn cwd_scan() -> &'static [(u32, String, PathBuf)] {
244 CWD_SCAN_CACHE.get_or_init(raw_cwd_scan).as_slice()
245}
246
247pub(crate) fn prewarm_cwd_scan() {
252 let _ = cwd_scan();
253}
254
255#[cfg(target_os = "linux")]
256fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
257 let mut out = Vec::new();
258 let proc_dir = match std::fs::read_dir("/proc") {
259 Ok(d) => d,
260 Err(e) => {
261 warn_scan_failed(&format!("/proc unreadable: {}", e));
262 return out;
263 }
264 };
265 for entry in proc_dir.flatten() {
266 let name = entry.file_name();
267 let name = name.to_string_lossy();
268 let pid: u32 = match name.parse() {
269 Ok(n) => n,
270 Err(_) => continue,
271 };
272 let cwd_link = entry.path().join("cwd");
273 let cwd = match std::fs::read_link(&cwd_link) {
274 Ok(p) => p,
275 Err(_) => continue,
276 };
277 let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
281 let cmd = std::fs::read_to_string(entry.path().join("comm"))
282 .map(|s| s.trim().to_string())
283 .unwrap_or_default();
284 out.push((pid, cmd, cwd_canon));
285 }
286 out
287}
288
289#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
300fn is_suspicious_cmd(cmd: &str) -> bool {
301 if cmd.is_empty() {
302 return true;
303 }
304 let mut chars = cmd.chars();
305 let first = chars.next().unwrap();
306 let starts_ok = first == 'v' || first.is_ascii_digit();
307 if !starts_ok {
308 return false;
309 }
310 let mut seen_digit = first.is_ascii_digit();
311 for c in chars {
312 if c.is_ascii_digit() {
313 seen_digit = true;
314 } else if c != '.' {
315 return false;
316 }
317 }
318 seen_digit
319}
320
321#[cfg(target_os = "macos")]
322fn kernel_comm(pid: u32) -> Option<String> {
323 let out = Command::new("ps")
324 .args(["-o", "comm=", "-p", &pid.to_string()])
325 .output()
326 .ok()?;
327 if !out.status.success() {
328 return None;
329 }
330 let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
331 if raw.is_empty() {
332 return None;
333 }
334 let base = std::path::Path::new(&raw)
336 .file_name()
337 .map(|s| s.to_string_lossy().into_owned())
338 .unwrap_or(raw);
339 Some(base)
340}
341
342#[cfg(target_os = "macos")]
343fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
344 let mut out = Vec::new();
345 let output = match Command::new("lsof")
350 .args(["-a", "-d", "cwd", "-F", "pcn", "+c", "0"])
351 .output()
352 {
353 Ok(o) => o,
354 Err(e) => {
355 warn_scan_failed(&format!("lsof unavailable: {}", e));
356 return out;
357 }
358 };
359 if !output.status.success() && output.stdout.is_empty() {
360 warn_scan_failed("lsof returned no output");
361 return out;
362 }
363 let stdout = String::from_utf8_lossy(&output.stdout);
364
365 let mut cur_pid: Option<u32> = None;
366 let mut cur_cmd = String::new();
367 for line in stdout.lines() {
368 if let Some(rest) = line.strip_prefix('p') {
369 cur_pid = rest.parse().ok();
370 cur_cmd.clear();
371 } else if let Some(rest) = line.strip_prefix('c') {
372 cur_cmd = rest.to_string();
373 } else if let Some(rest) = line.strip_prefix('n') {
374 if let Some(pid) = cur_pid {
375 let cwd = PathBuf::from(rest);
376 let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
377 let cmd = if is_suspicious_cmd(&cur_cmd) {
378 kernel_comm(pid).unwrap_or_else(|| cur_cmd.clone())
379 } else {
380 cur_cmd.clone()
381 };
382 out.push((pid, cmd, cwd_canon));
383 }
384 }
385 }
386 out
387}
388
389#[cfg(not(any(target_os = "linux", target_os = "macos")))]
390fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
391 Vec::new()
392}
393
394pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
405 let exclude_tree = self_process_tree();
406 let exclude_siblings = self_siblings();
407 let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
408 let mut out = Vec::new();
409
410 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
417 if !is_excluded(entry.pid) {
418 out.push(BusyInfo {
419 pid: entry.pid,
420 cmd: entry.cmd,
421 cwd: worktree.to_path_buf(),
422 source: BusySource::Lockfile,
423 tier: BusyTier::Hard,
424 tty: None,
425 started_secs_ago: None,
426 });
427 }
428 }
429
430 for info in scan_cwd(worktree) {
431 if is_excluded(info.pid) {
432 continue;
433 }
434 if out.iter().any(|b| b.pid == info.pid) {
435 continue;
436 }
437 out.push(info);
438 }
439
440 out
441}
442
443pub fn detect_busy_lockfile_only(worktree: &Path) -> Vec<BusyInfo> {
461 let exclude_tree = self_process_tree();
469 let is_excluded = |pid: u32| exclude_tree.contains(&pid);
470 let mut out = Vec::new();
471
472 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
473 if !is_excluded(entry.pid) {
474 out.push(BusyInfo {
475 pid: entry.pid,
476 cmd: entry.cmd,
477 cwd: worktree.to_path_buf(),
478 source: BusySource::Lockfile,
479 tier: BusyTier::Hard,
480 tty: None,
481 started_secs_ago: None,
482 });
483 }
484 }
485
486 out
487}
488
489const CLAUDE_ACTIVITY_THRESHOLD_MIN: i64 = 10;
491
492pub fn active_claude_sessions(worktree: &Path) -> Option<Vec<claude_session::ActiveSession>> {
502 let proj_dir = claude_session::project_dir_for(worktree)?;
503 let threshold = ChronoDuration::minutes(CLAUDE_ACTIVITY_THRESHOLD_MIN);
504 let sessions = claude_session::find_active_sessions(&proj_dir, worktree, threshold);
505 if sessions.is_empty() || !claude_process::has_live_claude_in(worktree) {
506 return None;
507 }
508 Some(sessions)
509}
510
511pub fn detect_busy_tiered(worktree: &Path) -> (Vec<BusyInfo>, Vec<BusyInfo>) {
524 let exclude_tree = self_process_tree();
525 let exclude_siblings = self_siblings();
526 let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
527
528 let mut hard = Vec::new();
529
530 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
532 if !is_excluded(entry.pid) {
533 hard.push(BusyInfo {
534 pid: entry.pid,
535 cmd: entry.cmd,
536 cwd: worktree.to_path_buf(),
537 source: BusySource::Lockfile,
538 tier: BusyTier::Hard,
539 tty: None,
540 started_secs_ago: None,
541 });
542 }
543 }
544
545 if let Some(sessions) = active_claude_sessions(worktree) {
549 for s in sessions {
550 let secs_ago = (chrono::Utc::now() - s.last_activity).num_seconds().max(0) as u64;
553 hard.push(BusyInfo {
554 pid: 0,
555 cmd: format!("claude (session {})", s.session_id),
556 cwd: worktree.to_path_buf(),
557 source: BusySource::ClaudeSession,
558 tier: BusyTier::Hard,
559 tty: None,
560 started_secs_ago: Some(secs_ago),
561 });
562 }
563 }
564
565 let mut soft = Vec::new();
569 for info in scan_cwd(worktree) {
570 if is_excluded(info.pid) {
571 continue;
572 }
573 if hard.iter().any(|b| b.pid == info.pid && b.pid != 0) {
574 continue;
575 }
576 soft.push(info);
577 }
578
579 (hard, soft)
580}
581
582fn is_multiplexer(cmd: &str) -> bool {
594 matches!(
595 cmd,
596 "zellij" | "tmux" | "tmux: server" | "tmate" | "tmate: server" | "screen" | "SCREEN"
597 )
598}
599
600fn scan_cwd(worktree: &Path) -> Vec<BusyInfo> {
601 let canon_target = match worktree.canonicalize() {
602 Ok(p) => p,
603 Err(_) => return Vec::new(),
604 };
605 let mut out = Vec::new();
606 for (pid, cmd, cwd) in cwd_scan() {
607 if cwd.starts_with(&canon_target) {
610 if is_multiplexer(cmd) {
611 continue;
612 }
613 out.push(BusyInfo {
614 pid: *pid,
615 cmd: cmd.clone(),
616 cwd: cwd.clone(),
617 source: BusySource::ProcessScan,
618 tier: BusyTier::Soft,
619 tty: None,
620 started_secs_ago: None,
621 });
622 }
623 }
624 out
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[test]
632 fn is_suspicious_cmd_flags_version_strings() {
633 assert!(is_suspicious_cmd(""));
634 assert!(is_suspicious_cmd("2.1.104"));
635 assert!(is_suspicious_cmd("0.0.1"));
636 assert!(is_suspicious_cmd("v1.2.3"));
637 assert!(is_suspicious_cmd("42"));
638 }
639
640 #[test]
641 fn is_suspicious_cmd_accepts_real_names() {
642 assert!(!is_suspicious_cmd("claude"));
643 assert!(!is_suspicious_cmd("node"));
644 assert!(!is_suspicious_cmd("zsh"));
645 assert!(!is_suspicious_cmd("tmux: server"));
646 assert!(!is_suspicious_cmd("python3"));
647 assert!(!is_suspicious_cmd("v"));
648 assert!(!is_suspicious_cmd("vim"));
649 }
650
651 #[test]
652 fn is_multiplexer_matches_known_names() {
653 for name in [
654 "zellij",
655 "tmux",
656 "tmux: server",
657 "tmate",
658 "tmate: server",
659 "screen",
660 "SCREEN",
661 ] {
662 assert!(is_multiplexer(name), "expected match for {:?}", name);
663 }
664 }
665
666 #[test]
667 fn is_multiplexer_rejects_non_multiplexers() {
668 for name in [
669 "",
670 "zsh",
671 "bash",
672 "claude",
673 "tmuxinator",
674 "ztmux",
675 "zellij-server",
676 "Screen",
677 ] {
678 assert!(!is_multiplexer(name), "expected no match for {:?}", name);
679 }
680 }
681
682 #[test]
683 fn self_tree_contains_current_pid() {
684 let tree = self_process_tree();
685 assert!(tree.contains(&std::process::id()));
686 }
687
688 #[cfg(unix)]
689 #[test]
690 fn self_tree_contains_parent_pid() {
691 let tree = self_process_tree();
692 let ppid = unsafe { libc::getppid() } as u32;
693 assert!(
694 tree.contains(&ppid),
695 "expected tree to contain ppid {}",
696 ppid
697 );
698 }
699
700 #[test]
701 fn detect_busy_tiered_returns_hard_for_lockfile() {
702 use std::process::{Command, Stdio};
703 let dir = tempfile::tempdir().unwrap();
704 let git_dir = dir.path().join(".git");
706 std::fs::create_dir_all(&git_dir).unwrap();
707 let mut child = Command::new("sleep")
711 .arg("30")
712 .stdout(Stdio::null())
713 .stderr(Stdio::null())
714 .spawn()
715 .expect("spawn sleep");
716 let child_pid = child.id();
717 let entry = crate::operations::lockfile::LockEntry {
720 version: crate::operations::lockfile::LOCK_VERSION,
721 pid: child_pid,
722 started_at: 0,
723 cmd: "claude".to_string(),
724 };
725 std::fs::write(
726 git_dir.join("gw-session.lock"),
727 serde_json::to_string(&entry).unwrap(),
728 )
729 .unwrap();
730 let (hard, _soft) = detect_busy_tiered(dir.path());
731 let _ = child.kill();
732 let _ = child.wait();
733 assert!(hard
734 .iter()
735 .any(|b| matches!(b.source, BusySource::Lockfile)));
736 assert!(hard.iter().all(|b| matches!(b.tier, BusyTier::Hard)));
737 }
738
739 #[cfg(any(target_os = "linux", target_os = "macos"))]
740 #[test]
741 fn scan_cwd_finds_child_with_cwd_in_tempdir() {
742 use std::process::{Command, Stdio};
743 use std::thread::sleep;
744 use std::time::{Duration, Instant};
745
746 let dir = tempfile::TempDir::new().unwrap();
747 let mut child = Command::new("sleep")
748 .arg("30")
749 .current_dir(dir.path())
750 .stdout(Stdio::null())
751 .stderr(Stdio::null())
752 .spawn()
753 .expect("spawn sleep");
754
755 sleep(Duration::from_millis(50));
760 let canon = dir
761 .path()
762 .canonicalize()
763 .unwrap_or(dir.path().to_path_buf());
764 let matches = |raw: &[(u32, String, std::path::PathBuf)]| -> bool {
765 raw.iter()
766 .any(|(p, _, cwd)| *p == child.id() && cwd.starts_with(&canon))
767 };
768 let mut found = matches(&raw_cwd_scan());
769 if !found {
770 let deadline = Instant::now() + Duration::from_secs(2);
771 while Instant::now() < deadline {
772 if matches(&raw_cwd_scan()) {
773 found = true;
774 break;
775 }
776 sleep(Duration::from_millis(50));
777 }
778 }
779
780 let _ = child.kill();
781 let _ = child.wait();
782
783 assert!(
784 found,
785 "expected to find child pid={} with cwd in {:?}",
786 child.id(),
787 dir.path()
788 );
789 }
790
791 #[cfg(any(target_os = "linux", target_os = "macos"))]
802 #[test]
803 fn detect_busy_tiered_no_hard_when_jsonl_active_but_no_live_claude() {
804 use crate::operations::test_env::{env_lock, EnvGuard};
805 let _lock = env_lock();
806 let _guard = EnvGuard::capture(&["HOME"]);
807
808 let home = tempfile::tempdir().unwrap();
809 std::env::set_var("HOME", home.path());
810
811 let wt = tempfile::tempdir().unwrap();
812 let wt_canon = wt.path().canonicalize().unwrap_or(wt.path().to_path_buf());
813
814 let encoded = wt_canon.to_string_lossy().replace(['/', '.'], "-");
817 let proj_dir = home.path().join(".claude").join("projects").join(encoded);
818 std::fs::create_dir_all(&proj_dir).unwrap();
819
820 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
824 let line = serde_json::json!({
825 "timestamp": now,
826 "cwd": wt_canon.to_string_lossy(),
827 });
828 std::fs::write(
829 proj_dir.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
830 format!("{}\n", line),
831 )
832 .unwrap();
833
834 let (hard, _soft) = detect_busy_tiered(wt.path());
835 assert!(
836 !hard
837 .iter()
838 .any(|b| matches!(b.source, BusySource::ClaudeSession)),
839 "expected no Hard ClaudeSession when no live claude holds the worktree, got: {:?}",
840 hard
841 );
842 }
843}