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 CWD_SCAN_CACHE: OnceLock<Vec<(u32, String, PathBuf)>> = OnceLock::new();
44
45static SCAN_WARNING: OnceLock<()> = OnceLock::new();
49
50fn compute_self_tree() -> HashSet<u32> {
51 let mut tree = HashSet::new();
52 tree.insert(std::process::id());
53
54 #[cfg(unix)]
55 {
56 let mut pid = unsafe { libc::getppid() } as u32;
57 for _ in 0..64 {
58 if pid == 0 {
60 break;
61 }
62 if pid == 1 {
66 tree.insert(pid);
67 break;
68 }
69 tree.insert(pid);
70 match parent_of(pid) {
71 Some(ppid) if ppid != pid => pid = ppid,
72 _ => break,
73 }
74 }
75 }
76 tree
77}
78
79pub fn self_process_tree() -> &'static HashSet<u32> {
83 SELF_TREE.get_or_init(compute_self_tree)
84}
85
86#[cfg(target_os = "linux")]
87fn parent_of(pid: u32) -> Option<u32> {
88 let status = std::fs::read_to_string(format!("/proc/{}/status", pid)).ok()?;
89 for line in status.lines() {
90 if let Some(rest) = line.strip_prefix("PPid:") {
91 return rest.trim().parse().ok();
92 }
93 }
94 None
95}
96
97#[cfg(target_os = "macos")]
98fn parent_of(pid: u32) -> Option<u32> {
99 let out = Command::new("ps")
100 .args(["-o", "ppid=", "-p", &pid.to_string()])
101 .output()
102 .ok()?;
103 if !out.status.success() {
104 return None;
105 }
106 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
107}
108
109#[cfg(not(any(target_os = "linux", target_os = "macos")))]
110#[allow(dead_code)]
111fn parent_of(_pid: u32) -> Option<u32> {
112 None
113}
114
115#[allow(dead_code)]
116fn warn_scan_failed(what: &str) {
117 if SCAN_WARNING.set(()).is_ok() {
118 eprintln!(
119 "{} could not scan processes: {}",
120 console::style("warning:").yellow(),
121 what
122 );
123 }
124}
125
126fn cwd_scan() -> &'static [(u32, String, PathBuf)] {
128 CWD_SCAN_CACHE.get_or_init(raw_cwd_scan).as_slice()
129}
130
131#[cfg(target_os = "linux")]
132fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
133 let mut out = Vec::new();
134 let proc_dir = match std::fs::read_dir("/proc") {
135 Ok(d) => d,
136 Err(e) => {
137 warn_scan_failed(&format!("/proc unreadable: {}", e));
138 return out;
139 }
140 };
141 for entry in proc_dir.flatten() {
142 let name = entry.file_name();
143 let name = name.to_string_lossy();
144 let pid: u32 = match name.parse() {
145 Ok(n) => n,
146 Err(_) => continue,
147 };
148 let cwd_link = entry.path().join("cwd");
149 let cwd = match std::fs::read_link(&cwd_link) {
150 Ok(p) => p,
151 Err(_) => continue,
152 };
153 let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
157 let cmd = std::fs::read_to_string(entry.path().join("comm"))
158 .map(|s| s.trim().to_string())
159 .unwrap_or_default();
160 out.push((pid, cmd, cwd_canon));
161 }
162 out
163}
164
165#[cfg(target_os = "macos")]
166fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
167 let mut out = Vec::new();
168 let output = match Command::new("lsof")
173 .args(["-a", "-d", "cwd", "-F", "pcn", "+c", "0"])
174 .output()
175 {
176 Ok(o) => o,
177 Err(e) => {
178 warn_scan_failed(&format!("lsof unavailable: {}", e));
179 return out;
180 }
181 };
182 if !output.status.success() && output.stdout.is_empty() {
183 warn_scan_failed("lsof returned no output");
184 return out;
185 }
186 let stdout = String::from_utf8_lossy(&output.stdout);
187
188 let mut cur_pid: Option<u32> = None;
189 let mut cur_cmd = String::new();
190 for line in stdout.lines() {
191 if let Some(rest) = line.strip_prefix('p') {
192 cur_pid = rest.parse().ok();
193 cur_cmd.clear();
194 } else if let Some(rest) = line.strip_prefix('c') {
195 cur_cmd = rest.to_string();
196 } else if let Some(rest) = line.strip_prefix('n') {
197 if let Some(pid) = cur_pid {
198 let cwd = PathBuf::from(rest);
199 let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
200 out.push((pid, cur_cmd.clone(), cwd_canon));
201 }
202 }
203 }
204 out
205}
206
207#[cfg(not(any(target_os = "linux", target_os = "macos")))]
208fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
209 Vec::new()
210}
211
212pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
223 let exclude = self_process_tree();
224 let mut out = Vec::new();
225
226 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
232 if !exclude.contains(&entry.pid) {
233 out.push(BusyInfo {
234 pid: entry.pid,
235 cmd: entry.cmd,
236 cwd: worktree.to_path_buf(),
237 source: BusySource::Lockfile,
238 });
239 }
240 }
241
242 for info in scan_cwd(worktree) {
243 if exclude.contains(&info.pid) {
244 continue;
245 }
246 if out.iter().any(|b| b.pid == info.pid) {
247 continue;
248 }
249 out.push(info);
250 }
251
252 out
253}
254
255fn is_multiplexer(cmd: &str) -> bool {
267 matches!(
268 cmd,
269 "zellij" | "tmux" | "tmux: server" | "tmate" | "tmate: server" | "screen" | "SCREEN"
270 )
271}
272
273fn scan_cwd(worktree: &Path) -> Vec<BusyInfo> {
274 let canon_target = match worktree.canonicalize() {
275 Ok(p) => p,
276 Err(_) => return Vec::new(),
277 };
278 let mut out = Vec::new();
279 for (pid, cmd, cwd) in cwd_scan() {
280 if cwd.starts_with(&canon_target) {
283 if is_multiplexer(cmd) {
284 continue;
285 }
286 out.push(BusyInfo {
287 pid: *pid,
288 cmd: cmd.clone(),
289 cwd: cwd.clone(),
290 source: BusySource::ProcessScan,
291 });
292 }
293 }
294 out
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn is_multiplexer_matches_known_names() {
303 for name in [
304 "zellij",
305 "tmux",
306 "tmux: server",
307 "tmate",
308 "tmate: server",
309 "screen",
310 "SCREEN",
311 ] {
312 assert!(is_multiplexer(name), "expected match for {:?}", name);
313 }
314 }
315
316 #[test]
317 fn is_multiplexer_rejects_non_multiplexers() {
318 for name in [
319 "",
320 "zsh",
321 "bash",
322 "claude",
323 "tmuxinator",
324 "ztmux",
325 "zellij-server",
326 "Screen",
327 ] {
328 assert!(!is_multiplexer(name), "expected no match for {:?}", name);
329 }
330 }
331
332 #[test]
333 fn self_tree_contains_current_pid() {
334 let tree = self_process_tree();
335 assert!(tree.contains(&std::process::id()));
336 }
337
338 #[cfg(unix)]
339 #[test]
340 fn self_tree_contains_parent_pid() {
341 let tree = self_process_tree();
342 let ppid = unsafe { libc::getppid() } as u32;
343 assert!(
344 tree.contains(&ppid),
345 "expected tree to contain ppid {}",
346 ppid
347 );
348 }
349
350 #[cfg(any(target_os = "linux", target_os = "macos"))]
351 #[test]
352 fn scan_cwd_finds_child_with_cwd_in_tempdir() {
353 use std::process::{Command, Stdio};
354 use std::thread::sleep;
355 use std::time::{Duration, Instant};
356
357 let dir = tempfile::TempDir::new().unwrap();
358 let mut child = Command::new("sleep")
359 .arg("30")
360 .current_dir(dir.path())
361 .stdout(Stdio::null())
362 .stderr(Stdio::null())
363 .spawn()
364 .expect("spawn sleep");
365
366 sleep(Duration::from_millis(50));
371 let canon = dir
372 .path()
373 .canonicalize()
374 .unwrap_or(dir.path().to_path_buf());
375 let matches = |raw: &[(u32, String, std::path::PathBuf)]| -> bool {
376 raw.iter()
377 .any(|(p, _, cwd)| *p == child.id() && cwd.starts_with(&canon))
378 };
379 let mut found = matches(&raw_cwd_scan());
380 if !found {
381 let deadline = Instant::now() + Duration::from_secs(2);
382 while Instant::now() < deadline {
383 if matches(&raw_cwd_scan()) {
384 found = true;
385 break;
386 }
387 sleep(Duration::from_millis(50));
388 }
389 }
390
391 let _ = child.kill();
392 let _ = child.wait();
393
394 assert!(
395 found,
396 "expected to find child pid={} with cwd in {:?}",
397 child.id(),
398 dir.path()
399 );
400 }
401}