git_worktree_manager/operations/
busy.rs1use 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_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
247#[cfg(target_os = "linux")]
248fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
249 let mut out = Vec::new();
250 let proc_dir = match std::fs::read_dir("/proc") {
251 Ok(d) => d,
252 Err(e) => {
253 warn_scan_failed(&format!("/proc unreadable: {}", e));
254 return out;
255 }
256 };
257 for entry in proc_dir.flatten() {
258 let name = entry.file_name();
259 let name = name.to_string_lossy();
260 let pid: u32 = match name.parse() {
261 Ok(n) => n,
262 Err(_) => continue,
263 };
264 let cwd_link = entry.path().join("cwd");
265 let cwd = match std::fs::read_link(&cwd_link) {
266 Ok(p) => p,
267 Err(_) => continue,
268 };
269 let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
273 let cmd = std::fs::read_to_string(entry.path().join("comm"))
274 .map(|s| s.trim().to_string())
275 .unwrap_or_default();
276 out.push((pid, cmd, cwd_canon));
277 }
278 out
279}
280
281#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
292fn is_suspicious_cmd(cmd: &str) -> bool {
293 if cmd.is_empty() {
294 return true;
295 }
296 let mut chars = cmd.chars();
297 let first = chars.next().unwrap();
298 let starts_ok = first == 'v' || first.is_ascii_digit();
299 if !starts_ok {
300 return false;
301 }
302 let mut seen_digit = first.is_ascii_digit();
303 for c in chars {
304 if c.is_ascii_digit() {
305 seen_digit = true;
306 } else if c != '.' {
307 return false;
308 }
309 }
310 seen_digit
311}
312
313#[cfg(target_os = "macos")]
314fn kernel_comm(pid: u32) -> Option<String> {
315 let out = Command::new("ps")
316 .args(["-o", "comm=", "-p", &pid.to_string()])
317 .output()
318 .ok()?;
319 if !out.status.success() {
320 return None;
321 }
322 let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
323 if raw.is_empty() {
324 return None;
325 }
326 let base = std::path::Path::new(&raw)
328 .file_name()
329 .map(|s| s.to_string_lossy().into_owned())
330 .unwrap_or(raw);
331 Some(base)
332}
333
334#[cfg(target_os = "macos")]
335fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
336 let mut out = Vec::new();
337 let output = match Command::new("lsof")
342 .args(["-a", "-d", "cwd", "-F", "pcn", "+c", "0"])
343 .output()
344 {
345 Ok(o) => o,
346 Err(e) => {
347 warn_scan_failed(&format!("lsof unavailable: {}", e));
348 return out;
349 }
350 };
351 if !output.status.success() && output.stdout.is_empty() {
352 warn_scan_failed("lsof returned no output");
353 return out;
354 }
355 let stdout = String::from_utf8_lossy(&output.stdout);
356
357 let mut cur_pid: Option<u32> = None;
358 let mut cur_cmd = String::new();
359 for line in stdout.lines() {
360 if let Some(rest) = line.strip_prefix('p') {
361 cur_pid = rest.parse().ok();
362 cur_cmd.clear();
363 } else if let Some(rest) = line.strip_prefix('c') {
364 cur_cmd = rest.to_string();
365 } else if let Some(rest) = line.strip_prefix('n') {
366 if let Some(pid) = cur_pid {
367 let cwd = PathBuf::from(rest);
368 let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
369 let cmd = if is_suspicious_cmd(&cur_cmd) {
370 kernel_comm(pid).unwrap_or_else(|| cur_cmd.clone())
371 } else {
372 cur_cmd.clone()
373 };
374 out.push((pid, cmd, cwd_canon));
375 }
376 }
377 }
378 out
379}
380
381#[cfg(not(any(target_os = "linux", target_os = "macos")))]
382fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
383 Vec::new()
384}
385
386pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
397 let exclude_tree = self_process_tree();
398 let exclude_siblings = self_siblings();
399 let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
400 let mut out = Vec::new();
401
402 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
409 if !is_excluded(entry.pid) {
410 out.push(BusyInfo {
411 pid: entry.pid,
412 cmd: entry.cmd,
413 cwd: worktree.to_path_buf(),
414 source: BusySource::Lockfile,
415 tier: BusyTier::Hard,
416 tty: None,
417 started_secs_ago: None,
418 });
419 }
420 }
421
422 for info in scan_cwd(worktree) {
423 if is_excluded(info.pid) {
424 continue;
425 }
426 if out.iter().any(|b| b.pid == info.pid) {
427 continue;
428 }
429 out.push(info);
430 }
431
432 out
433}
434
435pub fn detect_busy_lockfile_only(worktree: &Path) -> Vec<BusyInfo> {
453 let exclude_tree = self_process_tree();
461 let is_excluded = |pid: u32| exclude_tree.contains(&pid);
462 let mut out = Vec::new();
463
464 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
465 if !is_excluded(entry.pid) {
466 out.push(BusyInfo {
467 pid: entry.pid,
468 cmd: entry.cmd,
469 cwd: worktree.to_path_buf(),
470 source: BusySource::Lockfile,
471 tier: BusyTier::Hard,
472 tty: None,
473 started_secs_ago: None,
474 });
475 }
476 }
477
478 out
479}
480
481const CLAUDE_ACTIVITY_THRESHOLD_MIN: i64 = 10;
483
484pub fn detect_busy_tiered(worktree: &Path) -> (Vec<BusyInfo>, Vec<BusyInfo>) {
496 let exclude_tree = self_process_tree();
497 let exclude_siblings = self_siblings();
498 let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
499
500 let mut hard = Vec::new();
501
502 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
504 if !is_excluded(entry.pid) {
505 hard.push(BusyInfo {
506 pid: entry.pid,
507 cmd: entry.cmd,
508 cwd: worktree.to_path_buf(),
509 source: BusySource::Lockfile,
510 tier: BusyTier::Hard,
511 tty: None,
512 started_secs_ago: None,
513 });
514 }
515 }
516
517 if let Some(proj_dir) = claude_session::project_dir_for(worktree) {
519 let threshold = ChronoDuration::minutes(CLAUDE_ACTIVITY_THRESHOLD_MIN);
520 for s in claude_session::find_active_sessions(&proj_dir, worktree, threshold) {
521 let secs_ago = (chrono::Utc::now() - s.last_activity).num_seconds().max(0) as u64;
524 hard.push(BusyInfo {
525 pid: 0,
526 cmd: format!("claude (session {})", s.session_id),
527 cwd: worktree.to_path_buf(),
528 source: BusySource::ClaudeSession,
529 tier: BusyTier::Hard,
530 tty: None,
531 started_secs_ago: Some(secs_ago),
532 });
533 }
534 }
535
536 let mut soft = Vec::new();
540 for info in scan_cwd(worktree) {
541 if is_excluded(info.pid) {
542 continue;
543 }
544 if hard.iter().any(|b| b.pid == info.pid && b.pid != 0) {
545 continue;
546 }
547 soft.push(info);
548 }
549
550 (hard, soft)
551}
552
553fn is_multiplexer(cmd: &str) -> bool {
565 matches!(
566 cmd,
567 "zellij" | "tmux" | "tmux: server" | "tmate" | "tmate: server" | "screen" | "SCREEN"
568 )
569}
570
571fn scan_cwd(worktree: &Path) -> Vec<BusyInfo> {
572 let canon_target = match worktree.canonicalize() {
573 Ok(p) => p,
574 Err(_) => return Vec::new(),
575 };
576 let mut out = Vec::new();
577 for (pid, cmd, cwd) in cwd_scan() {
578 if cwd.starts_with(&canon_target) {
581 if is_multiplexer(cmd) {
582 continue;
583 }
584 out.push(BusyInfo {
585 pid: *pid,
586 cmd: cmd.clone(),
587 cwd: cwd.clone(),
588 source: BusySource::ProcessScan,
589 tier: BusyTier::Soft,
590 tty: None,
591 started_secs_ago: None,
592 });
593 }
594 }
595 out
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn is_suspicious_cmd_flags_version_strings() {
604 assert!(is_suspicious_cmd(""));
605 assert!(is_suspicious_cmd("2.1.104"));
606 assert!(is_suspicious_cmd("0.0.1"));
607 assert!(is_suspicious_cmd("v1.2.3"));
608 assert!(is_suspicious_cmd("42"));
609 }
610
611 #[test]
612 fn is_suspicious_cmd_accepts_real_names() {
613 assert!(!is_suspicious_cmd("claude"));
614 assert!(!is_suspicious_cmd("node"));
615 assert!(!is_suspicious_cmd("zsh"));
616 assert!(!is_suspicious_cmd("tmux: server"));
617 assert!(!is_suspicious_cmd("python3"));
618 assert!(!is_suspicious_cmd("v"));
619 assert!(!is_suspicious_cmd("vim"));
620 }
621
622 #[test]
623 fn is_multiplexer_matches_known_names() {
624 for name in [
625 "zellij",
626 "tmux",
627 "tmux: server",
628 "tmate",
629 "tmate: server",
630 "screen",
631 "SCREEN",
632 ] {
633 assert!(is_multiplexer(name), "expected match for {:?}", name);
634 }
635 }
636
637 #[test]
638 fn is_multiplexer_rejects_non_multiplexers() {
639 for name in [
640 "",
641 "zsh",
642 "bash",
643 "claude",
644 "tmuxinator",
645 "ztmux",
646 "zellij-server",
647 "Screen",
648 ] {
649 assert!(!is_multiplexer(name), "expected no match for {:?}", name);
650 }
651 }
652
653 #[test]
654 fn self_tree_contains_current_pid() {
655 let tree = self_process_tree();
656 assert!(tree.contains(&std::process::id()));
657 }
658
659 #[cfg(unix)]
660 #[test]
661 fn self_tree_contains_parent_pid() {
662 let tree = self_process_tree();
663 let ppid = unsafe { libc::getppid() } as u32;
664 assert!(
665 tree.contains(&ppid),
666 "expected tree to contain ppid {}",
667 ppid
668 );
669 }
670
671 #[test]
672 fn detect_busy_tiered_returns_hard_for_lockfile() {
673 use std::process::{Command, Stdio};
674 let dir = tempfile::tempdir().unwrap();
675 let git_dir = dir.path().join(".git");
677 std::fs::create_dir_all(&git_dir).unwrap();
678 let mut child = Command::new("sleep")
682 .arg("30")
683 .stdout(Stdio::null())
684 .stderr(Stdio::null())
685 .spawn()
686 .expect("spawn sleep");
687 let child_pid = child.id();
688 let entry = crate::operations::lockfile::LockEntry {
691 version: crate::operations::lockfile::LOCK_VERSION,
692 pid: child_pid,
693 started_at: 0,
694 cmd: "claude".to_string(),
695 };
696 std::fs::write(
697 git_dir.join("gw-session.lock"),
698 serde_json::to_string(&entry).unwrap(),
699 )
700 .unwrap();
701 let (hard, _soft) = detect_busy_tiered(dir.path());
702 let _ = child.kill();
703 let _ = child.wait();
704 assert!(hard
705 .iter()
706 .any(|b| matches!(b.source, BusySource::Lockfile)));
707 assert!(hard.iter().all(|b| matches!(b.tier, BusyTier::Hard)));
708 }
709
710 #[cfg(any(target_os = "linux", target_os = "macos"))]
711 #[test]
712 fn scan_cwd_finds_child_with_cwd_in_tempdir() {
713 use std::process::{Command, Stdio};
714 use std::thread::sleep;
715 use std::time::{Duration, Instant};
716
717 let dir = tempfile::TempDir::new().unwrap();
718 let mut child = Command::new("sleep")
719 .arg("30")
720 .current_dir(dir.path())
721 .stdout(Stdio::null())
722 .stderr(Stdio::null())
723 .spawn()
724 .expect("spawn sleep");
725
726 sleep(Duration::from_millis(50));
731 let canon = dir
732 .path()
733 .canonicalize()
734 .unwrap_or(dir.path().to_path_buf());
735 let matches = |raw: &[(u32, String, std::path::PathBuf)]| -> bool {
736 raw.iter()
737 .any(|(p, _, cwd)| *p == child.id() && cwd.starts_with(&canon))
738 };
739 let mut found = matches(&raw_cwd_scan());
740 if !found {
741 let deadline = Instant::now() + Duration::from_secs(2);
742 while Instant::now() < deadline {
743 if matches(&raw_cwd_scan()) {
744 found = true;
745 break;
746 }
747 sleep(Duration::from_millis(50));
748 }
749 }
750
751 let _ = child.kill();
752 let _ = child.wait();
753
754 assert!(
755 found,
756 "expected to find child pid={} with cwd in {:?}",
757 child.id(),
758 dir.path()
759 );
760 }
761}