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")
171 .args(["-a", "-d", "cwd", "-F", "pcn"])
172 .output()
173 {
174 Ok(o) => o,
175 Err(e) => {
176 warn_scan_failed(&format!("lsof unavailable: {}", e));
177 return out;
178 }
179 };
180 if !output.status.success() && output.stdout.is_empty() {
181 warn_scan_failed("lsof returned no output");
182 return out;
183 }
184 let stdout = String::from_utf8_lossy(&output.stdout);
185
186 let mut cur_pid: Option<u32> = None;
187 let mut cur_cmd = String::new();
188 for line in stdout.lines() {
189 if let Some(rest) = line.strip_prefix('p') {
190 cur_pid = rest.parse().ok();
191 cur_cmd.clear();
192 } else if let Some(rest) = line.strip_prefix('c') {
193 cur_cmd = rest.to_string();
194 } else if let Some(rest) = line.strip_prefix('n') {
195 if let Some(pid) = cur_pid {
196 let cwd = PathBuf::from(rest);
197 let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
198 out.push((pid, cur_cmd.clone(), cwd_canon));
199 }
200 }
201 }
202 out
203}
204
205#[cfg(not(any(target_os = "linux", target_os = "macos")))]
206fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
207 Vec::new()
208}
209
210pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
221 let exclude = self_process_tree();
222 let mut out = Vec::new();
223
224 if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
230 if !exclude.contains(&entry.pid) {
231 out.push(BusyInfo {
232 pid: entry.pid,
233 cmd: entry.cmd,
234 cwd: worktree.to_path_buf(),
235 source: BusySource::Lockfile,
236 });
237 }
238 }
239
240 for info in scan_cwd(worktree) {
241 if exclude.contains(&info.pid) {
242 continue;
243 }
244 if out.iter().any(|b| b.pid == info.pid) {
245 continue;
246 }
247 out.push(info);
248 }
249
250 out
251}
252
253fn scan_cwd(worktree: &Path) -> Vec<BusyInfo> {
254 let canon_target = match worktree.canonicalize() {
255 Ok(p) => p,
256 Err(_) => return Vec::new(),
257 };
258 let mut out = Vec::new();
259 for (pid, cmd, cwd) in cwd_scan() {
260 if cwd.starts_with(&canon_target) {
263 out.push(BusyInfo {
264 pid: *pid,
265 cmd: cmd.clone(),
266 cwd: cwd.clone(),
267 source: BusySource::ProcessScan,
268 });
269 }
270 }
271 out
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn self_tree_contains_current_pid() {
280 let tree = self_process_tree();
281 assert!(tree.contains(&std::process::id()));
282 }
283
284 #[cfg(unix)]
285 #[test]
286 fn self_tree_contains_parent_pid() {
287 let tree = self_process_tree();
288 let ppid = unsafe { libc::getppid() } as u32;
289 assert!(
290 tree.contains(&ppid),
291 "expected tree to contain ppid {}",
292 ppid
293 );
294 }
295
296 #[cfg(any(target_os = "linux", target_os = "macos"))]
297 #[test]
298 fn scan_cwd_finds_child_with_cwd_in_tempdir() {
299 use std::process::{Command, Stdio};
300 use std::thread::sleep;
301 use std::time::{Duration, Instant};
302
303 let dir = tempfile::TempDir::new().unwrap();
304 let mut child = Command::new("sleep")
305 .arg("30")
306 .current_dir(dir.path())
307 .stdout(Stdio::null())
308 .stderr(Stdio::null())
309 .spawn()
310 .expect("spawn sleep");
311
312 sleep(Duration::from_millis(50));
317 let canon = dir
318 .path()
319 .canonicalize()
320 .unwrap_or(dir.path().to_path_buf());
321 let matches = |raw: &[(u32, String, std::path::PathBuf)]| -> bool {
322 raw.iter()
323 .any(|(p, _, cwd)| *p == child.id() && cwd.starts_with(&canon))
324 };
325 let mut found = matches(&raw_cwd_scan());
326 if !found {
327 let deadline = Instant::now() + Duration::from_secs(2);
328 while Instant::now() < deadline {
329 if matches(&raw_cwd_scan()) {
330 found = true;
331 break;
332 }
333 sleep(Duration::from_millis(50));
334 }
335 }
336
337 let _ = child.kill();
338 let _ = child.wait();
339
340 assert!(
341 found,
342 "expected to find child pid={} with cwd in {:?}",
343 child.id(),
344 dir.path()
345 );
346 }
347}