Skip to main content

git_worktree_manager/operations/
busy.rs

1//! Busy detection: determine whether a worktree is currently in use.
2//!
3//! Two signals are combined:
4//!   1. Session lockfile (explicit — `gw shell`/`gw start` write one)
5//!   2. Process cwd scan (implicit — catches external `cd` + tool usage)
6//!
7//! The current process and its ancestor chain are excluded so that Claude
8//! Code or a parent shell invoking `gw delete` on its own worktree does
9//! not self-detect as busy.
10
11use 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/// Signal source that flagged a process as busy.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum BusySource {
22    Lockfile,
23    ProcessScan,
24}
25
26/// Information about a single process holding a worktree busy.
27#[derive(Debug, Clone)]
28pub struct BusyInfo {
29    pub pid: u32,
30    pub cmd: String,
31    /// For lockfile sources, this is the worktree path (the process's
32    /// actual cwd is unknown). For process-scan sources, this is the
33    /// process's canonicalized cwd.
34    pub cwd: PathBuf,
35    pub source: BusySource,
36}
37
38/// Cached self-process-tree for the lifetime of this `gw` invocation.
39static SELF_TREE: OnceLock<HashSet<u32>> = OnceLock::new();
40
41/// Cached raw cwd scan. On unix this is populated once per `gw` invocation
42/// (lsof / /proc walk is expensive). Each entry: (pid, cmd, canon_cwd).
43static CWD_SCAN_CACHE: OnceLock<Vec<(u32, String, PathBuf)>> = OnceLock::new();
44
45/// Emits the "could not scan processes" warning at most once per process.
46/// `gw` is short-lived so this is appropriate; a long-running daemon using
47/// this module would need to rework this (currently not a use case).
48static 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            // PID 0 is a kernel/orphan marker, not a userland process — skip.
59            if pid == 0 {
60                break;
61            }
62            // PID 1 (init/launchd) IS our ancestor when gw was reparented, so
63            // exclude it from busy detection just like any other ancestor.
64            // Stop walking: init has no meaningful parent for our purposes.
65            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
79/// Returns the current process + all ancestor PIDs (via getppid chain).
80/// Memoized for the lifetime of the process — the ancestry does not change
81/// during a single `gw` invocation.
82pub 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
126/// Populate and return the cached cwd scan (all processes, not filtered).
127fn 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        // canonicalize so symlinked / bind-mounted cwds match the target.
154        // On Linux, readlink on /proc/<pid>/cwd returns " (deleted)" if the
155        // process's cwd was unlinked; canonicalize fails and we fall back.
156        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    // `lsof -a -d cwd -F pcn` prints records of the form:
169    //   p<pid>\nc<cmd>\nn<path>\n
170    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
210/// Detect busy processes for a given worktree path.
211///
212/// Combines the lockfile signal and a process cwd scan. Filters out the
213/// current process tree so `gw delete` invoked from within the worktree
214/// does not self-report as busy.
215///
216/// Note: `detect_busy` calls `lockfile::read_and_clean_stale`, which removes
217/// lockfiles belonging to dead owners as a self-healing side effect. This
218/// means even read-only operations like `gw list` may mutate
219/// `<worktree>/.git/gw-session.lock` when a stale file is encountered.
220pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
221    let exclude = self_process_tree();
222    let mut out = Vec::new();
223
224    // Invariant: lockfile entries are pushed before the cwd scan so the
225    // dedup check below keeps the lockfile's richer `cmd` (e.g. "claude").
226    // Edge case: if the lockfile PID is in self_tree it is skipped entirely,
227    // and other PIDs found by the cwd scan are reported with whatever name
228    // `/proc/*/comm` or `lsof` provided — not the lockfile's cmd.
229    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        // Both sides were canonicalized upstream (handles macOS /var vs
261        // /private/var skew). This starts_with is the containment check.
262        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        // Give the OS a beat to register the child's cwd so the first scan
313        // usually succeeds; then fall back to polling for slow CI hosts.
314        // raw_cwd_scan() bypasses the module-static cache (which may have
315        // been populated before the child existed).
316        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}