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::{claude_session, lockfile};
18use chrono::Duration as ChronoDuration;
19
20/// Tier of a busy signal — controls refusal *strength* in `gw delete`.
21/// Hard signals (active Claude session, explicit lockfile) refuse with a
22/// strong message. Soft signals (process cwd scan) refuse with a warning.
23/// Both tiers are overridable by the same `--force` flag.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BusyTier {
26    Hard,
27    Soft,
28}
29
30/// Signal source that flagged a process as busy.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum BusySource {
33    Lockfile,
34    ClaudeSession,
35    ProcessScan,
36}
37
38/// Information about a single process holding a worktree busy.
39#[derive(Debug, Clone)]
40pub struct BusyInfo {
41    pub pid: u32,
42    pub cmd: String,
43    /// For lockfile sources, this is the worktree path (the process's
44    /// actual cwd is unknown). For process-scan sources, this is the
45    /// process's canonicalized cwd. For ClaudeSession, this is the worktree.
46    pub cwd: PathBuf,
47    pub source: BusySource,
48    pub tier: BusyTier,
49    /// Whether the process has a controlling TTY (interactive hint).
50    /// `None` if not determined (e.g. on Windows or for ClaudeSession).
51    pub tty: Option<bool>,
52    /// Approximate seconds since the process started, if known.
53    pub started_secs_ago: Option<u64>,
54}
55
56/// Cached self-process-tree for the lifetime of this `gw` invocation.
57static SELF_TREE: OnceLock<HashSet<u32>> = OnceLock::new();
58
59/// Cached sibling set — processes sharing `gw`'s direct parent PID, captured
60/// once per invocation. This covers shell pipeline co-members (e.g. when a
61/// user runs `gw list | head` the `head` process is gw's sibling, not an
62/// ancestor) and a few other co-spawned helpers.
63static SELF_SIBLINGS: OnceLock<HashSet<u32>> = OnceLock::new();
64
65/// Cached raw cwd scan. On unix this is populated once per `gw` invocation
66/// (lsof / /proc walk is expensive). Each entry: (pid, cmd, canon_cwd).
67static CWD_SCAN_CACHE: OnceLock<Vec<(u32, String, PathBuf)>> = OnceLock::new();
68
69/// Emits the "could not scan processes" warning at most once per process.
70/// `gw` is short-lived so this is appropriate; a long-running daemon using
71/// this module would need to rework this (currently not a use case).
72static SCAN_WARNING: OnceLock<()> = OnceLock::new();
73
74fn compute_self_tree() -> HashSet<u32> {
75    let mut tree = HashSet::new();
76    tree.insert(std::process::id());
77
78    #[cfg(unix)]
79    {
80        let mut pid = unsafe { libc::getppid() } as u32;
81        for _ in 0..64 {
82            // PID 0 is a kernel/orphan marker, not a userland process — skip.
83            if pid == 0 {
84                break;
85            }
86            // PID 1 (init/launchd) IS our ancestor when gw was reparented, so
87            // exclude it from busy detection just like any other ancestor.
88            // Stop walking: init has no meaningful parent for our purposes.
89            if pid == 1 {
90                tree.insert(pid);
91                break;
92            }
93            tree.insert(pid);
94            match parent_of(pid) {
95                Some(ppid) if ppid != pid => pid = ppid,
96                _ => break,
97            }
98        }
99    }
100    tree
101}
102
103/// Returns the current process + all ancestor PIDs (via getppid chain).
104/// Memoized for the lifetime of the process — the ancestry does not change
105/// during a single `gw` invocation.
106pub fn self_process_tree() -> &'static HashSet<u32> {
107    SELF_TREE.get_or_init(compute_self_tree)
108}
109
110/// Compute the set of processes sharing `gw`'s process group ID.
111///
112/// Shells set up pipelines (`gw list | head | awk`) by putting all members
113/// in a single process group that becomes the foreground job. Using pgid
114/// as the sibling criterion matches exactly those pipeline co-members and
115/// excludes them from busy detection — they inherited the shell's cwd but
116/// are transient artifacts of the current command, not real occupants.
117///
118/// This is deliberately narrower than "processes sharing our ppid": the
119/// broader criterion would also exclude legitimate busy processes that
120/// happen to be spawned by the same parent as `gw` (e.g. a test harness
121/// running both a long-lived worker and `gw` from the same Cargo runner).
122#[cfg(unix)]
123fn compute_self_siblings() -> HashSet<u32> {
124    let mut siblings = HashSet::new();
125    let our_pid = std::process::id();
126    let our_pgid = unsafe { libc::getpgrp() } as u32;
127    if our_pgid == 0 || our_pgid == 1 {
128        return siblings;
129    }
130    // Distinguish two scenarios with the same raw pgid test:
131    //   (a) gw is a member of a shell pipeline (`gw list | head`). The shell
132    //       placed the pipeline in its own process group, so our pgid differs
133    //       from our parent's pgid. Pipeline co-members share our pgid and
134    //       are safe to exclude.
135    //   (b) gw was spawned by a non-shell parent that did not call setpgid
136    //       (e.g. `cargo test` spawning both gw and a long-lived worker).
137    //       Our pgid equals our parent's pgid, which means "same pgid" also
138    //       matches unrelated siblings that legitimately occupy a worktree.
139    //       In this case we return an empty set and let the ancestor-only
140    //       filter handle things.
141    let parent_pid = unsafe { libc::getppid() } as u32;
142    if parent_pid == 0 {
143        return siblings;
144    }
145    let parent_pgid = pgid_of(parent_pid).unwrap_or(0);
146    if parent_pgid == our_pgid {
147        return siblings;
148    }
149    for (pid, _, _) in cwd_scan() {
150        if *pid == our_pid {
151            continue;
152        }
153        if let Some(pgid) = pgid_of(*pid) {
154            if pgid == our_pgid {
155                siblings.insert(*pid);
156            }
157        }
158    }
159    siblings
160}
161
162#[cfg(not(unix))]
163fn compute_self_siblings() -> HashSet<u32> {
164    HashSet::new()
165}
166
167#[cfg(target_os = "linux")]
168fn pgid_of(pid: u32) -> Option<u32> {
169    let status = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
170    // /proc/<pid>/stat: "pid (comm) state ppid pgid ..."
171    // Parse from the last ')' to avoid confusion with spaces/parens in comm.
172    let after_comm = status.rsplit_once(')')?.1;
173    let fields: Vec<&str> = after_comm.split_whitespace().collect();
174    // After ')' the fields are: state ppid pgid ...
175    // So pgid is index 2.
176    fields.get(2)?.parse().ok()
177}
178
179#[cfg(target_os = "macos")]
180fn pgid_of(pid: u32) -> Option<u32> {
181    let out = Command::new("ps")
182        .args(["-o", "pgid=", "-p", &pid.to_string()])
183        .output()
184        .ok()?;
185    if !out.status.success() {
186        return None;
187    }
188    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
189}
190
191#[cfg(not(any(target_os = "linux", target_os = "macos")))]
192#[allow(dead_code)]
193fn pgid_of(_pid: u32) -> Option<u32> {
194    None
195}
196
197/// Returns the memoized sibling set (see `compute_self_siblings`).
198pub fn self_siblings() -> &'static HashSet<u32> {
199    SELF_SIBLINGS.get_or_init(compute_self_siblings)
200}
201
202#[cfg(target_os = "linux")]
203fn parent_of(pid: u32) -> Option<u32> {
204    let status = std::fs::read_to_string(format!("/proc/{}/status", pid)).ok()?;
205    for line in status.lines() {
206        if let Some(rest) = line.strip_prefix("PPid:") {
207            return rest.trim().parse().ok();
208        }
209    }
210    None
211}
212
213#[cfg(target_os = "macos")]
214fn parent_of(pid: u32) -> Option<u32> {
215    let out = Command::new("ps")
216        .args(["-o", "ppid=", "-p", &pid.to_string()])
217        .output()
218        .ok()?;
219    if !out.status.success() {
220        return None;
221    }
222    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
223}
224
225#[cfg(not(any(target_os = "linux", target_os = "macos")))]
226#[allow(dead_code)]
227fn parent_of(_pid: u32) -> Option<u32> {
228    None
229}
230
231#[allow(dead_code)]
232fn warn_scan_failed(what: &str) {
233    if SCAN_WARNING.set(()).is_ok() {
234        eprintln!(
235            "{} could not scan processes: {}",
236            console::style("warning:").yellow(),
237            what
238        );
239    }
240}
241
242/// Populate and return the cached cwd scan (all processes, not filtered).
243fn cwd_scan() -> &'static [(u32, String, PathBuf)] {
244    CWD_SCAN_CACHE.get_or_init(raw_cwd_scan).as_slice()
245}
246
247#[cfg(target_os = "linux")]
248fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
249    let mut out = Vec::new();
250    let proc_dir = match std::fs::read_dir("/proc") {
251        Ok(d) => d,
252        Err(e) => {
253            warn_scan_failed(&format!("/proc unreadable: {}", e));
254            return out;
255        }
256    };
257    for entry in proc_dir.flatten() {
258        let name = entry.file_name();
259        let name = name.to_string_lossy();
260        let pid: u32 = match name.parse() {
261            Ok(n) => n,
262            Err(_) => continue,
263        };
264        let cwd_link = entry.path().join("cwd");
265        let cwd = match std::fs::read_link(&cwd_link) {
266            Ok(p) => p,
267            Err(_) => continue,
268        };
269        // canonicalize so symlinked / bind-mounted cwds match the target.
270        // On Linux, readlink on /proc/<pid>/cwd returns " (deleted)" if the
271        // process's cwd was unlinked; canonicalize fails and we fall back.
272        let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
273        let cmd = std::fs::read_to_string(entry.path().join("comm"))
274            .map(|s| s.trim().to_string())
275            .unwrap_or_default();
276        out.push((pid, cmd, cwd_canon));
277    }
278    out
279}
280
281/// Heuristic: does a cmd string look like an argv[0] that was overwritten
282/// with a version or status string rather than a program name? Example from
283/// the wild: Claude Code rewrites argv[0] to "2.1.104". `lsof` reports argv[0]
284/// for macOS processes, so these junk values bleed into busy reporting.
285/// We detect the pattern (all digits, dots, and optional leading `v`) and
286/// fall back to a `ps -o comm=` lookup, which returns the kernel-recorded
287/// basename.
288///
289/// Linux's `/proc/<pid>/comm` already reports the kernel-recorded name so
290/// this heuristic is only used on macOS; the tests remain cross-platform.
291#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
292fn is_suspicious_cmd(cmd: &str) -> bool {
293    if cmd.is_empty() {
294        return true;
295    }
296    let mut chars = cmd.chars();
297    let first = chars.next().unwrap();
298    let starts_ok = first == 'v' || first.is_ascii_digit();
299    if !starts_ok {
300        return false;
301    }
302    let mut seen_digit = first.is_ascii_digit();
303    for c in chars {
304        if c.is_ascii_digit() {
305            seen_digit = true;
306        } else if c != '.' {
307            return false;
308        }
309    }
310    seen_digit
311}
312
313#[cfg(target_os = "macos")]
314fn kernel_comm(pid: u32) -> Option<String> {
315    let out = Command::new("ps")
316        .args(["-o", "comm=", "-p", &pid.to_string()])
317        .output()
318        .ok()?;
319    if !out.status.success() {
320        return None;
321    }
322    let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
323    if raw.is_empty() {
324        return None;
325    }
326    // `ps -o comm=` on macOS returns the full executable path. Take basename.
327    let base = std::path::Path::new(&raw)
328        .file_name()
329        .map(|s| s.to_string_lossy().into_owned())
330        .unwrap_or(raw);
331    Some(base)
332}
333
334#[cfg(target_os = "macos")]
335fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
336    let mut out = Vec::new();
337    // `lsof -a -d cwd -F pcn` prints records of the form:
338    //   p<pid>\nc<cmd>\nn<path>\n
339    // `+c 0` disables lsof's default 9-char COMMAND truncation so multi-word
340    // names like "tmux: server" survive intact for the multiplexer filter.
341    let output = match Command::new("lsof")
342        .args(["-a", "-d", "cwd", "-F", "pcn", "+c", "0"])
343        .output()
344    {
345        Ok(o) => o,
346        Err(e) => {
347            warn_scan_failed(&format!("lsof unavailable: {}", e));
348            return out;
349        }
350    };
351    if !output.status.success() && output.stdout.is_empty() {
352        warn_scan_failed("lsof returned no output");
353        return out;
354    }
355    let stdout = String::from_utf8_lossy(&output.stdout);
356
357    let mut cur_pid: Option<u32> = None;
358    let mut cur_cmd = String::new();
359    for line in stdout.lines() {
360        if let Some(rest) = line.strip_prefix('p') {
361            cur_pid = rest.parse().ok();
362            cur_cmd.clear();
363        } else if let Some(rest) = line.strip_prefix('c') {
364            cur_cmd = rest.to_string();
365        } else if let Some(rest) = line.strip_prefix('n') {
366            if let Some(pid) = cur_pid {
367                let cwd = PathBuf::from(rest);
368                let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
369                let cmd = if is_suspicious_cmd(&cur_cmd) {
370                    kernel_comm(pid).unwrap_or_else(|| cur_cmd.clone())
371                } else {
372                    cur_cmd.clone()
373                };
374                out.push((pid, cmd, cwd_canon));
375            }
376        }
377    }
378    out
379}
380
381#[cfg(not(any(target_os = "linux", target_os = "macos")))]
382fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
383    Vec::new()
384}
385
386/// Detect busy processes for a given worktree path.
387///
388/// Combines the lockfile signal and a process cwd scan. Filters out the
389/// current process tree so `gw delete` invoked from within the worktree
390/// does not self-report as busy.
391///
392/// Note: `detect_busy` calls `lockfile::read_and_clean_stale`, which removes
393/// lockfiles belonging to dead owners as a self-healing side effect. This
394/// means even read-only operations like `gw list` may mutate
395/// `<worktree>/.git/gw-session.lock` when a stale file is encountered.
396pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
397    let exclude_tree = self_process_tree();
398    let exclude_siblings = self_siblings();
399    let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
400    let mut out = Vec::new();
401
402    // Invariant: lockfile entries are pushed before the cwd scan so the
403    // dedup check below keeps the lockfile's richer `cmd` (e.g. "claude").
404    // Edge case: if the lockfile PID is in self_tree/self_siblings it is
405    // skipped entirely, and other PIDs found by the cwd scan are reported
406    // with whatever name `/proc/*/comm` or `lsof` provided — not the
407    // lockfile's cmd.
408    if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
409        if !is_excluded(entry.pid) {
410            out.push(BusyInfo {
411                pid: entry.pid,
412                cmd: entry.cmd,
413                cwd: worktree.to_path_buf(),
414                source: BusySource::Lockfile,
415                tier: BusyTier::Hard,
416                tty: None,
417                started_secs_ago: None,
418            });
419        }
420    }
421
422    for info in scan_cwd(worktree) {
423        if is_excluded(info.pid) {
424            continue;
425        }
426        if out.iter().any(|b| b.pid == info.pid) {
427            continue;
428        }
429        out.push(info);
430    }
431
432    out
433}
434
435/// Fast busy detection using only the session lockfile.
436///
437/// Unlike [`detect_busy`], this does not perform a system-wide process cwd
438/// scan (lsof on macOS, /proc walk on Linux). The cwd scan takes ~1.5s on
439/// typical macOS systems and dominates `gw list` latency, so read-only
440/// display paths use this variant.
441///
442/// This trades coverage for speed: worktrees entered via external `cd`
443/// without a `gw shell`/`gw start` session will not be flagged as busy.
444/// Commands that need strong busy guarantees (`gw delete`, `gw clean`)
445/// continue to use [`detect_busy`].
446///
447/// Like [`detect_busy`], this calls [`lockfile::read_and_clean_stale`]
448/// and may silently remove a stale `<worktree>/.git/gw-session.lock` as
449/// a self-healing side effect. `gw list` (the primary caller) therefore
450/// mutates lockfiles on every invocation, even though it is nominally
451/// read-only.
452pub fn detect_busy_lockfile_only(worktree: &Path) -> Vec<BusyInfo> {
453    // Skip self_siblings: it internally triggers cwd_scan (lsof / /proc walk)
454    // which is exactly what this fast path exists to avoid. Pipeline co-members
455    // of this gw invocation are short-lived CLI tools (e.g. `gw list | head`)
456    // that never call `gw shell`/`gw start`, so they cannot own a lockfile.
457    // Ancestor-only exclusion is sufficient in practice — and in the rare case
458    // where a true sibling (e.g. a backgrounded `gw start`) does own a
459    // lockfile, reporting its worktree as busy is correct, not a false positive.
460    let exclude_tree = self_process_tree();
461    let is_excluded = |pid: u32| exclude_tree.contains(&pid);
462    let mut out = Vec::new();
463
464    if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
465        if !is_excluded(entry.pid) {
466            out.push(BusyInfo {
467                pid: entry.pid,
468                cmd: entry.cmd,
469                cwd: worktree.to_path_buf(),
470                source: BusySource::Lockfile,
471                tier: BusyTier::Hard,
472                tty: None,
473                started_secs_ago: None,
474            });
475        }
476    }
477
478    out
479}
480
481/// Threshold for considering a Claude jsonl event "active." Spec value.
482const CLAUDE_ACTIVITY_THRESHOLD_MIN: i64 = 10;
483
484/// Tiered busy detection: returns `(hard, soft)` separately so the caller
485/// can render distinct refusal messages.
486///
487/// Hard signals (refuse strongly, override = `--force`):
488///   * Active Claude Code session (jsonl event within threshold)
489///   * Explicit lockfile
490///
491/// Soft signals (refuse with a warning, same `--force` override):
492///   * Process cwd scan results that are not already represented by a
493///     Hard signal (deduped by PID; PID 0 sentinels for ClaudeSession
494///     are not deduped).
495pub fn detect_busy_tiered(worktree: &Path) -> (Vec<BusyInfo>, Vec<BusyInfo>) {
496    let exclude_tree = self_process_tree();
497    let exclude_siblings = self_siblings();
498    let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
499
500    let mut hard = Vec::new();
501
502    // Hard: lockfile
503    if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
504        if !is_excluded(entry.pid) {
505            hard.push(BusyInfo {
506                pid: entry.pid,
507                cmd: entry.cmd,
508                cwd: worktree.to_path_buf(),
509                source: BusySource::Lockfile,
510                tier: BusyTier::Hard,
511                tty: None,
512                started_secs_ago: None,
513            });
514        }
515    }
516
517    // Hard: active Claude sessions
518    if let Some(proj_dir) = claude_session::project_dir_for(worktree) {
519        let threshold = ChronoDuration::minutes(CLAUDE_ACTIVITY_THRESHOLD_MIN);
520        for s in claude_session::find_active_sessions(&proj_dir, worktree, threshold) {
521            // session_id is a UUID; surface as cmd "claude (session <id>)" with
522            // PID 0 as a sentinel meaning "not a process PID, informational entry".
523            let secs_ago = (chrono::Utc::now() - s.last_activity).num_seconds().max(0) as u64;
524            hard.push(BusyInfo {
525                pid: 0,
526                cmd: format!("claude (session {})", s.session_id),
527                cwd: worktree.to_path_buf(),
528                source: BusySource::ClaudeSession,
529                tier: BusyTier::Hard,
530                tty: None,
531                started_secs_ago: Some(secs_ago),
532            });
533        }
534    }
535
536    // Soft: process cwd scan, deduped against PIDs already in Hard (PID 0
537    // sentinels for ClaudeSession do not participate in dedup since real
538    // processes have non-zero PIDs).
539    let mut soft = Vec::new();
540    for info in scan_cwd(worktree) {
541        if is_excluded(info.pid) {
542            continue;
543        }
544        if hard.iter().any(|b| b.pid == info.pid && b.pid != 0) {
545            continue;
546        }
547        soft.push(info);
548    }
549
550    (hard, soft)
551}
552
553/// Terminal multiplexers whose server process may have been launched from
554/// within a worktree but does not meaningfully "occupy" it — the real work
555/// happens in child shells / tools, which the cwd scan reports independently.
556/// Reporting the multiplexer itself just produces noise when running
557/// `gw delete` from a pane hosted by that multiplexer.
558///
559/// Matched against `/proc/<pid>/comm` on Linux (≤15 chars; may reflect
560/// `prctl(PR_SET_NAME)` rather than argv[0], e.g. "tmux: server") or `lsof`'s
561/// COMMAND field on macOS (we pass `+c 0` to disable its default 9-char
562/// truncation — see `raw_cwd_scan`). GNU screen's detached server renames
563/// itself to uppercase "SCREEN" via prctl, so both cases are listed.
564fn is_multiplexer(cmd: &str) -> bool {
565    matches!(
566        cmd,
567        "zellij" | "tmux" | "tmux: server" | "tmate" | "tmate: server" | "screen" | "SCREEN"
568    )
569}
570
571fn scan_cwd(worktree: &Path) -> Vec<BusyInfo> {
572    let canon_target = match worktree.canonicalize() {
573        Ok(p) => p,
574        Err(_) => return Vec::new(),
575    };
576    let mut out = Vec::new();
577    for (pid, cmd, cwd) in cwd_scan() {
578        // Both sides were canonicalized upstream (handles macOS /var vs
579        // /private/var skew). This starts_with is the containment check.
580        if cwd.starts_with(&canon_target) {
581            if is_multiplexer(cmd) {
582                continue;
583            }
584            out.push(BusyInfo {
585                pid: *pid,
586                cmd: cmd.clone(),
587                cwd: cwd.clone(),
588                source: BusySource::ProcessScan,
589                tier: BusyTier::Soft,
590                tty: None,
591                started_secs_ago: None,
592            });
593        }
594    }
595    out
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn is_suspicious_cmd_flags_version_strings() {
604        assert!(is_suspicious_cmd(""));
605        assert!(is_suspicious_cmd("2.1.104"));
606        assert!(is_suspicious_cmd("0.0.1"));
607        assert!(is_suspicious_cmd("v1.2.3"));
608        assert!(is_suspicious_cmd("42"));
609    }
610
611    #[test]
612    fn is_suspicious_cmd_accepts_real_names() {
613        assert!(!is_suspicious_cmd("claude"));
614        assert!(!is_suspicious_cmd("node"));
615        assert!(!is_suspicious_cmd("zsh"));
616        assert!(!is_suspicious_cmd("tmux: server"));
617        assert!(!is_suspicious_cmd("python3"));
618        assert!(!is_suspicious_cmd("v"));
619        assert!(!is_suspicious_cmd("vim"));
620    }
621
622    #[test]
623    fn is_multiplexer_matches_known_names() {
624        for name in [
625            "zellij",
626            "tmux",
627            "tmux: server",
628            "tmate",
629            "tmate: server",
630            "screen",
631            "SCREEN",
632        ] {
633            assert!(is_multiplexer(name), "expected match for {:?}", name);
634        }
635    }
636
637    #[test]
638    fn is_multiplexer_rejects_non_multiplexers() {
639        for name in [
640            "",
641            "zsh",
642            "bash",
643            "claude",
644            "tmuxinator",
645            "ztmux",
646            "zellij-server",
647            "Screen",
648        ] {
649            assert!(!is_multiplexer(name), "expected no match for {:?}", name);
650        }
651    }
652
653    #[test]
654    fn self_tree_contains_current_pid() {
655        let tree = self_process_tree();
656        assert!(tree.contains(&std::process::id()));
657    }
658
659    #[cfg(unix)]
660    #[test]
661    fn self_tree_contains_parent_pid() {
662        let tree = self_process_tree();
663        let ppid = unsafe { libc::getppid() } as u32;
664        assert!(
665            tree.contains(&ppid),
666            "expected tree to contain ppid {}",
667            ppid
668        );
669    }
670
671    #[test]
672    fn detect_busy_tiered_returns_hard_for_lockfile() {
673        use std::process::{Command, Stdio};
674        let dir = tempfile::tempdir().unwrap();
675        // Mark a fake .git dir so lock_path resolves predictably.
676        let git_dir = dir.path().join(".git");
677        std::fs::create_dir_all(&git_dir).unwrap();
678        // Spawn a short-lived child process to get a live PID that is NOT in
679        // self_process_tree (which excludes all ancestors up to init, but NOT
680        // descendants). Use sleep so the child stays alive through the assertion.
681        let mut child = Command::new("sleep")
682            .arg("30")
683            .stdout(Stdio::null())
684            .stderr(Stdio::null())
685            .spawn()
686            .expect("spawn sleep");
687        let child_pid = child.id();
688        // Write the lockfile directly with the child's PID so it is live and
689        // not excluded by the ancestor-chain filter.
690        let entry = crate::operations::lockfile::LockEntry {
691            version: crate::operations::lockfile::LOCK_VERSION,
692            pid: child_pid,
693            started_at: 0,
694            cmd: "claude".to_string(),
695        };
696        std::fs::write(
697            git_dir.join("gw-session.lock"),
698            serde_json::to_string(&entry).unwrap(),
699        )
700        .unwrap();
701        let (hard, _soft) = detect_busy_tiered(dir.path());
702        let _ = child.kill();
703        let _ = child.wait();
704        assert!(hard
705            .iter()
706            .any(|b| matches!(b.source, BusySource::Lockfile)));
707        assert!(hard.iter().all(|b| matches!(b.tier, BusyTier::Hard)));
708    }
709
710    #[cfg(any(target_os = "linux", target_os = "macos"))]
711    #[test]
712    fn scan_cwd_finds_child_with_cwd_in_tempdir() {
713        use std::process::{Command, Stdio};
714        use std::thread::sleep;
715        use std::time::{Duration, Instant};
716
717        let dir = tempfile::TempDir::new().unwrap();
718        let mut child = Command::new("sleep")
719            .arg("30")
720            .current_dir(dir.path())
721            .stdout(Stdio::null())
722            .stderr(Stdio::null())
723            .spawn()
724            .expect("spawn sleep");
725
726        // Give the OS a beat to register the child's cwd so the first scan
727        // usually succeeds; then fall back to polling for slow CI hosts.
728        // raw_cwd_scan() bypasses the module-static cache (which may have
729        // been populated before the child existed).
730        sleep(Duration::from_millis(50));
731        let canon = dir
732            .path()
733            .canonicalize()
734            .unwrap_or(dir.path().to_path_buf());
735        let matches = |raw: &[(u32, String, std::path::PathBuf)]| -> bool {
736            raw.iter()
737                .any(|(p, _, cwd)| *p == child.id() && cwd.starts_with(&canon))
738        };
739        let mut found = matches(&raw_cwd_scan());
740        if !found {
741            let deadline = Instant::now() + Duration::from_secs(2);
742            while Instant::now() < deadline {
743                if matches(&raw_cwd_scan()) {
744                    found = true;
745                    break;
746                }
747                sleep(Duration::from_millis(50));
748            }
749        }
750
751        let _ = child.kill();
752        let _ = child.wait();
753
754        assert!(
755            found,
756            "expected to find child pid={} with cwd in {:?}",
757            child.id(),
758            dir.path()
759        );
760    }
761}