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    // `+c 0` disables lsof's default 9-char COMMAND truncation so multi-word
171    // names like "tmux: server" survive intact for the multiplexer filter.
172    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
212/// Detect busy processes for a given worktree path.
213///
214/// Combines the lockfile signal and a process cwd scan. Filters out the
215/// current process tree so `gw delete` invoked from within the worktree
216/// does not self-report as busy.
217///
218/// Note: `detect_busy` calls `lockfile::read_and_clean_stale`, which removes
219/// lockfiles belonging to dead owners as a self-healing side effect. This
220/// means even read-only operations like `gw list` may mutate
221/// `<worktree>/.git/gw-session.lock` when a stale file is encountered.
222pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
223    let exclude = self_process_tree();
224    let mut out = Vec::new();
225
226    // Invariant: lockfile entries are pushed before the cwd scan so the
227    // dedup check below keeps the lockfile's richer `cmd` (e.g. "claude").
228    // Edge case: if the lockfile PID is in self_tree it is skipped entirely,
229    // and other PIDs found by the cwd scan are reported with whatever name
230    // `/proc/*/comm` or `lsof` provided — not the lockfile's cmd.
231    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
255/// Terminal multiplexers whose server process may have been launched from
256/// within a worktree but does not meaningfully "occupy" it — the real work
257/// happens in child shells / tools, which the cwd scan reports independently.
258/// Reporting the multiplexer itself just produces noise when running
259/// `gw delete` from a pane hosted by that multiplexer.
260///
261/// Matched against `/proc/<pid>/comm` on Linux (≤15 chars; may reflect
262/// `prctl(PR_SET_NAME)` rather than argv[0], e.g. "tmux: server") or `lsof`'s
263/// COMMAND field on macOS (we pass `+c 0` to disable its default 9-char
264/// truncation — see `raw_cwd_scan`). GNU screen's detached server renames
265/// itself to uppercase "SCREEN" via prctl, so both cases are listed.
266fn 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        // Both sides were canonicalized upstream (handles macOS /var vs
281        // /private/var skew). This starts_with is the containment check.
282        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        // Give the OS a beat to register the child's cwd so the first scan
367        // usually succeeds; then fall back to polling for slow CI hosts.
368        // raw_cwd_scan() bypasses the module-static cache (which may have
369        // been populated before the child existed).
370        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}