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::lockfile;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum BusySource {
22 Lockfile,
23 ProcessScan,
24}
25
26#[derive(Debug, Clone)]
28pub struct BusyInfo {
29 pub pid: u32,
30 pub cmd: String,
31 pub cwd: PathBuf,
35 pub source: BusySource,
36}
37
38static SELF_TREE: OnceLock<HashSet<u32>> = OnceLock::new();
40
41static SELF_SIBLINGS: OnceLock<HashSet<u32>> = OnceLock::new();
46
47static CWD_SCAN_CACHE: OnceLock<Vec<(u32, String, PathBuf)>> = OnceLock::new();
50
51static SCAN_WARNING: OnceLock<()> = OnceLock::new();
55
56fn compute_self_tree() -> HashSet<u32> {
57 let mut tree = HashSet::new();
58 tree.insert(std::process::id());
59
60 #[cfg(unix)]
61 {
62 let mut pid = unsafe { libc::getppid() } as u32;
63 for _ in 0..64 {
64 if pid == 0 {
66 break;
67 }
68 if pid == 1 {
72 tree.insert(pid);
73 break;
74 }
75 tree.insert(pid);
76 match parent_of(pid) {
77 Some(ppid) if ppid != pid => pid = ppid,
78 _ => break,
79 }
80 }
81 }
82 tree
83}
84
85pub fn self_process_tree() -> &'static HashSet<u32> {
89 SELF_TREE.get_or_init(compute_self_tree)
90}
91
92#[cfg(unix)]
105fn compute_self_siblings() -> HashSet<u32> {
106 let mut siblings = HashSet::new();
107 let our_pid = std::process::id();
108 let our_pgid = unsafe { libc::getpgrp() } as u32;
109 if our_pgid == 0 || our_pgid == 1 {
110 return siblings;
111 }
112 let parent_pid = unsafe { libc::getppid() } as u32;
124 if parent_pid == 0 {
125 return siblings;
126 }
127 let parent_pgid = pgid_of(parent_pid).unwrap_or(0);
128 if parent_pgid == our_pgid {
129 return siblings;
130 }
131 for (pid, _, _) in cwd_scan() {
132 if *pid == our_pid {
133 continue;
134 }
135 if let Some(pgid) = pgid_of(*pid) {
136 if pgid == our_pgid {
137 siblings.insert(*pid);
138 }
139 }
140 }
141 siblings
142}
143
144#[cfg(not(unix))]
145fn compute_self_siblings() -> HashSet<u32> {
146 HashSet::new()
147}
148
149#[cfg(target_os = "linux")]
150fn pgid_of(pid: u32) -> Option<u32> {
151 let status = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
152 let after_comm = status.rsplit_once(')')?.1;
155 let fields: Vec<&str> = after_comm.split_whitespace().collect();
156 fields.get(2)?.parse().ok()
159}
160
161#[cfg(target_os = "macos")]
162fn pgid_of(pid: u32) -> Option<u32> {
163 let out = Command::new("ps")
164 .args(["-o", "pgid=", "-p", &pid.to_string()])
165 .output()
166 .ok()?;
167 if !out.status.success() {
168 return None;
169 }
170 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
171}
172
173#[cfg(not(any(target_os = "linux", target_os = "macos")))]
174#[allow(dead_code)]
175fn pgid_of(_pid: u32) -> Option<u32> {
176 None
177}
178
179pub fn self_siblings() -> &'static HashSet<u32> {
181 SELF_SIBLINGS.get_or_init(compute_self_siblings)
182}
183
184#[cfg(target_os = "linux")]
185fn parent_of(pid: u32) -> Option<u32> {
186 let status = std::fs::read_to_string(format!("/proc/{}/status", pid)).ok()?;
187 for line in status.lines() {
188 if let Some(rest) = line.strip_prefix("PPid:") {
189 return rest.trim().parse().ok();
190 }
191 }
192 None
193}
194
195#[cfg(target_os = "macos")]
196fn parent_of(pid: u32) -> Option<u32> {
197 let out = Command::new("ps")
198 .args(["-o", "ppid=", "-p", &pid.to_string()])
199 .output()
200 .ok()?;
201 if !out.status.success() {
202 return None;
203 }
204 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
205}
206
207#[cfg(not(any(target_os = "linux", target_os = "macos")))]
208#[allow(dead_code)]
209fn parent_of(_pid: u32) -> Option<u32> {
210 None
211}
212
213#[allow(dead_code)]
214fn warn_scan_failed(what: &str) {
215 if SCAN_WARNING.set(()).is_ok() {
216 eprintln!(
217 "{} could not scan processes: {}",
218 console::style("warning:").yellow(),
219 what
220 );
221 }
222}
223
224fn cwd_scan() -> &'static [(u32, String, PathBuf)] {
226 CWD_SCAN_CACHE.get_or_init(raw_cwd_scan).as_slice()
227}
228
229#[cfg(target_os = "linux")]
230fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
231 let mut out = Vec::new();
232 let proc_dir = match std::fs::read_dir("/proc") {
233 Ok(d) => d,
234 Err(e) => {
235 warn_scan_failed(&format!("/proc unreadable: {}", e));
236 return out;
237 }
238 };
239 for entry in proc_dir.flatten() {
240 let name = entry.file_name();
241 let name = name.to_string_lossy();
242 let pid: u32 = match name.parse() {
243 Ok(n) => n,
244 Err(_) => continue,
245 };
246 let cwd_link = entry.path().join("cwd");
247 let cwd = match std::fs::read_link(&cwd_link) {
248 Ok(p) => p,
249 Err(_) => continue,
250 };
251 let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
255 let cmd = std::fs::read_to_string(entry.path().join("comm"))
256 .map(|s| s.trim().to_string())
257 .unwrap_or_default();
258 out.push((pid, cmd, cwd_canon));
259 }
260 out
261}
262
263#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
274fn is_suspicious_cmd(cmd: &str) -> bool {
275 if cmd.is_empty() {
276 return true;
277 }
278 let mut chars = cmd.chars();
279 let first = chars.next().unwrap();
280 let starts_ok = first == 'v' || first.is_ascii_digit();
281 if !starts_ok {
282 return false;
283 }
284 let mut seen_digit = first.is_ascii_digit();
285 for c in chars {
286 if c.is_ascii_digit() {
287 seen_digit = true;
288 } else if c != '.' {
289 return false;
290 }
291 }
292 seen_digit
293}
294
295#[cfg(target_os = "macos")]
296fn kernel_comm(pid: u32) -> Option<String> {
297 let out = Command::new("ps")
298 .args(["-o", "comm=", "-p", &pid.to_string()])
299 .output()
300 .ok()?;
301 if !out.status.success() {
302 return None;
303 }
304 let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
305 if raw.is_empty() {
306 return None;
307 }
308 let base = std::path::Path::new(&raw)
310 .file_name()
311 .map(|s| s.to_string_lossy().into_owned())
312 .unwrap_or(raw);
313 Some(base)
314}
315
316#[cfg(target_os = "macos")]
317fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
318 let mut out = Vec::new();
319 let output = match Command::new("lsof")
324 .args(["-a", "-d", "cwd", "-F", "pcn", "+c", "0"])
325 .output()
326 {
327 Ok(o) => o,
328 Err(e) => {
329 warn_scan_failed(&format!("lsof unavailable: {}", e));
330 return out;
331 }
332 };
333 if !output.status.success() && output.stdout.is_empty() {
334 warn_scan_failed("lsof returned no output");
335 return out;
336 }
337 let stdout = String::from_utf8_lossy(&output.stdout);
338
339 let mut cur_pid: Option<u32> = None;
340 let mut cur_cmd = String::new();
341 for line in stdout.lines() {
342 if let Some(rest) = line.strip_prefix('p') {
343 cur_pid = rest.parse().ok();
344 cur_cmd.clear();
345 } else if let Some(rest) = line.strip_prefix('c') {
346 cur_cmd = rest.to_string();
347 } else if let Some(rest) = line.strip_prefix('n') {
348 if let Some(pid) = cur_pid {
349 let cwd = PathBuf::from(rest);
350 let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
351 let cmd = if is_suspicious_cmd(&cur_cmd) {
352 kernel_comm(pid).unwrap_or_else(|| cur_cmd.clone())
353 } else {
354 cur_cmd.clone()
355 };
356 out.push((pid, cmd, cwd_canon));
357 }
358 }
359 }
360 out
361}
362
363#[cfg(not(any(target_os = "linux", target_os = "macos")))]
364fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
365 Vec::new()
366}
367
368pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
379 let exclude_tree = self_process_tree();
380 let exclude_siblings = self_siblings();
381 let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
382 let mut out = Vec::new();
383
384 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
391 if !is_excluded(entry.pid) {
392 out.push(BusyInfo {
393 pid: entry.pid,
394 cmd: entry.cmd,
395 cwd: worktree.to_path_buf(),
396 source: BusySource::Lockfile,
397 });
398 }
399 }
400
401 for info in scan_cwd(worktree) {
402 if is_excluded(info.pid) {
403 continue;
404 }
405 if out.iter().any(|b| b.pid == info.pid) {
406 continue;
407 }
408 out.push(info);
409 }
410
411 out
412}
413
414fn is_multiplexer(cmd: &str) -> bool {
426 matches!(
427 cmd,
428 "zellij" | "tmux" | "tmux: server" | "tmate" | "tmate: server" | "screen" | "SCREEN"
429 )
430}
431
432fn scan_cwd(worktree: &Path) -> Vec<BusyInfo> {
433 let canon_target = match worktree.canonicalize() {
434 Ok(p) => p,
435 Err(_) => return Vec::new(),
436 };
437 let mut out = Vec::new();
438 for (pid, cmd, cwd) in cwd_scan() {
439 if cwd.starts_with(&canon_target) {
442 if is_multiplexer(cmd) {
443 continue;
444 }
445 out.push(BusyInfo {
446 pid: *pid,
447 cmd: cmd.clone(),
448 cwd: cwd.clone(),
449 source: BusySource::ProcessScan,
450 });
451 }
452 }
453 out
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn is_suspicious_cmd_flags_version_strings() {
462 assert!(is_suspicious_cmd(""));
463 assert!(is_suspicious_cmd("2.1.104"));
464 assert!(is_suspicious_cmd("0.0.1"));
465 assert!(is_suspicious_cmd("v1.2.3"));
466 assert!(is_suspicious_cmd("42"));
467 }
468
469 #[test]
470 fn is_suspicious_cmd_accepts_real_names() {
471 assert!(!is_suspicious_cmd("claude"));
472 assert!(!is_suspicious_cmd("node"));
473 assert!(!is_suspicious_cmd("zsh"));
474 assert!(!is_suspicious_cmd("tmux: server"));
475 assert!(!is_suspicious_cmd("python3"));
476 assert!(!is_suspicious_cmd("v"));
477 assert!(!is_suspicious_cmd("vim"));
478 }
479
480 #[test]
481 fn is_multiplexer_matches_known_names() {
482 for name in [
483 "zellij",
484 "tmux",
485 "tmux: server",
486 "tmate",
487 "tmate: server",
488 "screen",
489 "SCREEN",
490 ] {
491 assert!(is_multiplexer(name), "expected match for {:?}", name);
492 }
493 }
494
495 #[test]
496 fn is_multiplexer_rejects_non_multiplexers() {
497 for name in [
498 "",
499 "zsh",
500 "bash",
501 "claude",
502 "tmuxinator",
503 "ztmux",
504 "zellij-server",
505 "Screen",
506 ] {
507 assert!(!is_multiplexer(name), "expected no match for {:?}", name);
508 }
509 }
510
511 #[test]
512 fn self_tree_contains_current_pid() {
513 let tree = self_process_tree();
514 assert!(tree.contains(&std::process::id()));
515 }
516
517 #[cfg(unix)]
518 #[test]
519 fn self_tree_contains_parent_pid() {
520 let tree = self_process_tree();
521 let ppid = unsafe { libc::getppid() } as u32;
522 assert!(
523 tree.contains(&ppid),
524 "expected tree to contain ppid {}",
525 ppid
526 );
527 }
528
529 #[cfg(any(target_os = "linux", target_os = "macos"))]
530 #[test]
531 fn scan_cwd_finds_child_with_cwd_in_tempdir() {
532 use std::process::{Command, Stdio};
533 use std::thread::sleep;
534 use std::time::{Duration, Instant};
535
536 let dir = tempfile::TempDir::new().unwrap();
537 let mut child = Command::new("sleep")
538 .arg("30")
539 .current_dir(dir.path())
540 .stdout(Stdio::null())
541 .stderr(Stdio::null())
542 .spawn()
543 .expect("spawn sleep");
544
545 sleep(Duration::from_millis(50));
550 let canon = dir
551 .path()
552 .canonicalize()
553 .unwrap_or(dir.path().to_path_buf());
554 let matches = |raw: &[(u32, String, std::path::PathBuf)]| -> bool {
555 raw.iter()
556 .any(|(p, _, cwd)| *p == child.id() && cwd.starts_with(&canon))
557 };
558 let mut found = matches(&raw_cwd_scan());
559 if !found {
560 let deadline = Instant::now() + Duration::from_secs(2);
561 while Instant::now() < deadline {
562 if matches(&raw_cwd_scan()) {
563 found = true;
564 break;
565 }
566 sleep(Duration::from_millis(50));
567 }
568 }
569
570 let _ = child.kill();
571 let _ = child.wait();
572
573 assert!(
574 found,
575 "expected to find child pid={} with cwd in {:?}",
576 child.id(),
577 dir.path()
578 );
579 }
580}