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_process, 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/// Force-populate the cwd scan cache. Intended for parallel prewarm so the
248/// system-wide `lsof` runs concurrently with `claude_process::prewarm`.
249/// Safe to call from multiple threads — `OnceLock` ensures the scan runs
250/// at most once.
251pub(crate) fn prewarm_cwd_scan() {
252    let _ = cwd_scan();
253}
254
255#[cfg(target_os = "linux")]
256fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
257    let mut out = Vec::new();
258    let proc_dir = match std::fs::read_dir("/proc") {
259        Ok(d) => d,
260        Err(e) => {
261            warn_scan_failed(&format!("/proc unreadable: {}", e));
262            return out;
263        }
264    };
265    for entry in proc_dir.flatten() {
266        let name = entry.file_name();
267        let name = name.to_string_lossy();
268        let pid: u32 = match name.parse() {
269            Ok(n) => n,
270            Err(_) => continue,
271        };
272        let cwd_link = entry.path().join("cwd");
273        let cwd = match std::fs::read_link(&cwd_link) {
274            Ok(p) => p,
275            Err(_) => continue,
276        };
277        // canonicalize so symlinked / bind-mounted cwds match the target.
278        // On Linux, readlink on /proc/<pid>/cwd returns " (deleted)" if the
279        // process's cwd was unlinked; canonicalize fails and we fall back.
280        let cwd_canon = cwd.canonicalize().unwrap_or(cwd.clone());
281        let cmd = std::fs::read_to_string(entry.path().join("comm"))
282            .map(|s| s.trim().to_string())
283            .unwrap_or_default();
284        out.push((pid, cmd, cwd_canon));
285    }
286    out
287}
288
289/// Heuristic: does a cmd string look like an argv[0] that was overwritten
290/// with a version or status string rather than a program name? Example from
291/// the wild: Claude Code rewrites argv[0] to "2.1.104". `lsof` reports argv[0]
292/// for macOS processes, so these junk values bleed into busy reporting.
293/// We detect the pattern (all digits, dots, and optional leading `v`) and
294/// fall back to a `ps -o comm=` lookup, which returns the kernel-recorded
295/// basename.
296///
297/// Linux's `/proc/<pid>/comm` already reports the kernel-recorded name so
298/// this heuristic is only used on macOS; the tests remain cross-platform.
299#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
300fn is_suspicious_cmd(cmd: &str) -> bool {
301    if cmd.is_empty() {
302        return true;
303    }
304    let mut chars = cmd.chars();
305    let first = chars.next().unwrap();
306    let starts_ok = first == 'v' || first.is_ascii_digit();
307    if !starts_ok {
308        return false;
309    }
310    let mut seen_digit = first.is_ascii_digit();
311    for c in chars {
312        if c.is_ascii_digit() {
313            seen_digit = true;
314        } else if c != '.' {
315            return false;
316        }
317    }
318    seen_digit
319}
320
321#[cfg(target_os = "macos")]
322fn kernel_comm(pid: u32) -> Option<String> {
323    let out = Command::new("ps")
324        .args(["-o", "comm=", "-p", &pid.to_string()])
325        .output()
326        .ok()?;
327    if !out.status.success() {
328        return None;
329    }
330    let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
331    if raw.is_empty() {
332        return None;
333    }
334    // `ps -o comm=` on macOS returns the full executable path. Take basename.
335    let base = std::path::Path::new(&raw)
336        .file_name()
337        .map(|s| s.to_string_lossy().into_owned())
338        .unwrap_or(raw);
339    Some(base)
340}
341
342#[cfg(target_os = "macos")]
343fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
344    let mut out = Vec::new();
345    // `lsof -a -d cwd -F pcn` prints records of the form:
346    //   p<pid>\nc<cmd>\nn<path>\n
347    // `+c 0` disables lsof's default 9-char COMMAND truncation so multi-word
348    // names like "tmux: server" survive intact for the multiplexer filter.
349    let output = match Command::new("lsof")
350        .args(["-a", "-d", "cwd", "-F", "pcn", "+c", "0"])
351        .output()
352    {
353        Ok(o) => o,
354        Err(e) => {
355            warn_scan_failed(&format!("lsof unavailable: {}", e));
356            return out;
357        }
358    };
359    if !output.status.success() && output.stdout.is_empty() {
360        warn_scan_failed("lsof returned no output");
361        return out;
362    }
363    let stdout = String::from_utf8_lossy(&output.stdout);
364
365    let mut cur_pid: Option<u32> = None;
366    let mut cur_cmd = String::new();
367    for line in stdout.lines() {
368        if let Some(rest) = line.strip_prefix('p') {
369            cur_pid = rest.parse().ok();
370            cur_cmd.clear();
371        } else if let Some(rest) = line.strip_prefix('c') {
372            cur_cmd = rest.to_string();
373        } else if let Some(rest) = line.strip_prefix('n') {
374            if let Some(pid) = cur_pid {
375                let cwd = PathBuf::from(rest);
376                let cwd_canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone());
377                let cmd = if is_suspicious_cmd(&cur_cmd) {
378                    kernel_comm(pid).unwrap_or_else(|| cur_cmd.clone())
379                } else {
380                    cur_cmd.clone()
381                };
382                out.push((pid, cmd, cwd_canon));
383            }
384        }
385    }
386    out
387}
388
389#[cfg(not(any(target_os = "linux", target_os = "macos")))]
390fn raw_cwd_scan() -> Vec<(u32, String, PathBuf)> {
391    Vec::new()
392}
393
394/// Detect busy processes for a given worktree path.
395///
396/// Combines the lockfile signal and a process cwd scan. Filters out the
397/// current process tree so `gw delete` invoked from within the worktree
398/// does not self-report as busy.
399///
400/// Note: `detect_busy` calls `lockfile::read_and_clean_stale`, which removes
401/// lockfiles belonging to dead owners as a self-healing side effect. This
402/// means even read-only operations like `gw list` may mutate
403/// `<worktree>/.git/gw-session.lock` when a stale file is encountered.
404pub fn detect_busy(worktree: &Path) -> Vec<BusyInfo> {
405    let exclude_tree = self_process_tree();
406    let exclude_siblings = self_siblings();
407    let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
408    let mut out = Vec::new();
409
410    // Invariant: lockfile entries are pushed before the cwd scan so the
411    // dedup check below keeps the lockfile's richer `cmd` (e.g. "claude").
412    // Edge case: if the lockfile PID is in self_tree/self_siblings it is
413    // skipped entirely, and other PIDs found by the cwd scan are reported
414    // with whatever name `/proc/*/comm` or `lsof` provided — not the
415    // lockfile's cmd.
416    if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
417        if !is_excluded(entry.pid) {
418            out.push(BusyInfo {
419                pid: entry.pid,
420                cmd: entry.cmd,
421                cwd: worktree.to_path_buf(),
422                source: BusySource::Lockfile,
423                tier: BusyTier::Hard,
424                tty: None,
425                started_secs_ago: None,
426            });
427        }
428    }
429
430    for info in scan_cwd(worktree) {
431        if is_excluded(info.pid) {
432            continue;
433        }
434        if out.iter().any(|b| b.pid == info.pid) {
435            continue;
436        }
437        out.push(info);
438    }
439
440    out
441}
442
443/// Fast busy detection using only the session lockfile.
444///
445/// Unlike [`detect_busy`], this does not perform a system-wide process cwd
446/// scan (lsof on macOS, /proc walk on Linux). The cwd scan takes ~1.5s on
447/// typical macOS systems and dominates `gw list` latency, so read-only
448/// display paths use this variant.
449///
450/// This trades coverage for speed: worktrees entered via external `cd`
451/// without a `gw shell`/`gw start` session will not be flagged as busy.
452/// Commands that need strong busy guarantees (`gw delete`, `gw clean`)
453/// continue to use [`detect_busy`].
454///
455/// Like [`detect_busy`], this calls [`lockfile::read_and_clean_stale`]
456/// and may silently remove a stale `<worktree>/.git/gw-session.lock` as
457/// a self-healing side effect. `gw list` (the primary caller) therefore
458/// mutates lockfiles on every invocation, even though it is nominally
459/// read-only.
460pub fn detect_busy_lockfile_only(worktree: &Path) -> Vec<BusyInfo> {
461    // Skip self_siblings: it internally triggers cwd_scan (lsof / /proc walk)
462    // which is exactly what this fast path exists to avoid. Pipeline co-members
463    // of this gw invocation are short-lived CLI tools (e.g. `gw list | head`)
464    // that never call `gw shell`/`gw start`, so they cannot own a lockfile.
465    // Ancestor-only exclusion is sufficient in practice — and in the rare case
466    // where a true sibling (e.g. a backgrounded `gw start`) does own a
467    // lockfile, reporting its worktree as busy is correct, not a false positive.
468    let exclude_tree = self_process_tree();
469    let is_excluded = |pid: u32| exclude_tree.contains(&pid);
470    let mut out = Vec::new();
471
472    if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
473        if !is_excluded(entry.pid) {
474            out.push(BusyInfo {
475                pid: entry.pid,
476                cmd: entry.cmd,
477                cwd: worktree.to_path_buf(),
478                source: BusySource::Lockfile,
479                tier: BusyTier::Hard,
480                tty: None,
481                started_secs_ago: None,
482            });
483        }
484    }
485
486    out
487}
488
489/// Threshold for considering a Claude jsonl event "active." Spec value.
490const CLAUDE_ACTIVITY_THRESHOLD_MIN: i64 = 10;
491
492/// The two-stage "Claude is here" gate. Returns the list of active
493/// sessions iff (a) the jsonl tail has an event within the threshold AND
494/// (b) a live `claude` process is occupying `worktree` (cwd or `.claude`
495/// fd). Returns `None` when either gate fails or no project dir is found.
496///
497/// This is the single source of truth for the gate — `detect_busy_tiered`
498/// uses it for the full hard/soft dispatch in `gw delete`, and
499/// `display::get_worktree_status` uses it as the "busy" check for read-
500/// only surfaces (`gw status` / `gw list`).
501pub fn active_claude_sessions(worktree: &Path) -> Option<Vec<claude_session::ActiveSession>> {
502    let proj_dir = claude_session::project_dir_for(worktree)?;
503    let threshold = ChronoDuration::minutes(CLAUDE_ACTIVITY_THRESHOLD_MIN);
504    let sessions = claude_session::find_active_sessions(&proj_dir, worktree, threshold);
505    if sessions.is_empty() || !claude_process::has_live_claude_in(worktree) {
506        return None;
507    }
508    Some(sessions)
509}
510
511/// Tiered busy detection: returns `(hard, soft)` separately so the caller
512/// can render distinct refusal messages.
513///
514/// Hard signals (refuse strongly, override = `--force`):
515///   * Active Claude Code session: jsonl event within threshold AND a live
516///     `claude` process is occupying the worktree (cwd or `.claude` fd).
517///   * Explicit lockfile
518///
519/// Soft signals (refuse with a warning, same `--force` override):
520///   * Process cwd scan results that are not already represented by a
521///     Hard signal (deduped by PID; PID 0 sentinels for ClaudeSession
522///     are not deduped).
523pub fn detect_busy_tiered(worktree: &Path) -> (Vec<BusyInfo>, Vec<BusyInfo>) {
524    let exclude_tree = self_process_tree();
525    let exclude_siblings = self_siblings();
526    let is_excluded = |pid: u32| exclude_tree.contains(&pid) || exclude_siblings.contains(&pid);
527
528    let mut hard = Vec::new();
529
530    // Hard: lockfile
531    if let Some(entry) = lockfile::read_and_clean_stale(worktree) {
532        if !is_excluded(entry.pid) {
533            hard.push(BusyInfo {
534                pid: entry.pid,
535                cmd: entry.cmd,
536                cwd: worktree.to_path_buf(),
537                source: BusySource::Lockfile,
538                tier: BusyTier::Hard,
539                tty: None,
540                started_secs_ago: None,
541            });
542        }
543    }
544
545    // Hard: active Claude sessions. The two-stage gate (jsonl event AND
546    // live `claude` process) lives in `active_claude_sessions` so that
547    // read-only surfaces (`gw status` / `gw list`) share the same check.
548    if let Some(sessions) = active_claude_sessions(worktree) {
549        for s in sessions {
550            // session_id is a UUID; surface as cmd "claude (session <id>)" with
551            // PID 0 as a sentinel meaning "not a process PID, informational entry".
552            let secs_ago = (chrono::Utc::now() - s.last_activity).num_seconds().max(0) as u64;
553            hard.push(BusyInfo {
554                pid: 0,
555                cmd: format!("claude (session {})", s.session_id),
556                cwd: worktree.to_path_buf(),
557                source: BusySource::ClaudeSession,
558                tier: BusyTier::Hard,
559                tty: None,
560                started_secs_ago: Some(secs_ago),
561            });
562        }
563    }
564
565    // Soft: process cwd scan, deduped against PIDs already in Hard (PID 0
566    // sentinels for ClaudeSession do not participate in dedup since real
567    // processes have non-zero PIDs).
568    let mut soft = Vec::new();
569    for info in scan_cwd(worktree) {
570        if is_excluded(info.pid) {
571            continue;
572        }
573        if hard.iter().any(|b| b.pid == info.pid && b.pid != 0) {
574            continue;
575        }
576        soft.push(info);
577    }
578
579    (hard, soft)
580}
581
582/// Terminal multiplexers whose server process may have been launched from
583/// within a worktree but does not meaningfully "occupy" it — the real work
584/// happens in child shells / tools, which the cwd scan reports independently.
585/// Reporting the multiplexer itself just produces noise when running
586/// `gw delete` from a pane hosted by that multiplexer.
587///
588/// Matched against `/proc/<pid>/comm` on Linux (≤15 chars; may reflect
589/// `prctl(PR_SET_NAME)` rather than argv[0], e.g. "tmux: server") or `lsof`'s
590/// COMMAND field on macOS (we pass `+c 0` to disable its default 9-char
591/// truncation — see `raw_cwd_scan`). GNU screen's detached server renames
592/// itself to uppercase "SCREEN" via prctl, so both cases are listed.
593fn is_multiplexer(cmd: &str) -> bool {
594    matches!(
595        cmd,
596        "zellij" | "tmux" | "tmux: server" | "tmate" | "tmate: server" | "screen" | "SCREEN"
597    )
598}
599
600fn scan_cwd(worktree: &Path) -> Vec<BusyInfo> {
601    let canon_target = match worktree.canonicalize() {
602        Ok(p) => p,
603        Err(_) => return Vec::new(),
604    };
605    let mut out = Vec::new();
606    for (pid, cmd, cwd) in cwd_scan() {
607        // Both sides were canonicalized upstream (handles macOS /var vs
608        // /private/var skew). This starts_with is the containment check.
609        if cwd.starts_with(&canon_target) {
610            if is_multiplexer(cmd) {
611                continue;
612            }
613            out.push(BusyInfo {
614                pid: *pid,
615                cmd: cmd.clone(),
616                cwd: cwd.clone(),
617                source: BusySource::ProcessScan,
618                tier: BusyTier::Soft,
619                tty: None,
620                started_secs_ago: None,
621            });
622        }
623    }
624    out
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn is_suspicious_cmd_flags_version_strings() {
633        assert!(is_suspicious_cmd(""));
634        assert!(is_suspicious_cmd("2.1.104"));
635        assert!(is_suspicious_cmd("0.0.1"));
636        assert!(is_suspicious_cmd("v1.2.3"));
637        assert!(is_suspicious_cmd("42"));
638    }
639
640    #[test]
641    fn is_suspicious_cmd_accepts_real_names() {
642        assert!(!is_suspicious_cmd("claude"));
643        assert!(!is_suspicious_cmd("node"));
644        assert!(!is_suspicious_cmd("zsh"));
645        assert!(!is_suspicious_cmd("tmux: server"));
646        assert!(!is_suspicious_cmd("python3"));
647        assert!(!is_suspicious_cmd("v"));
648        assert!(!is_suspicious_cmd("vim"));
649    }
650
651    #[test]
652    fn is_multiplexer_matches_known_names() {
653        for name in [
654            "zellij",
655            "tmux",
656            "tmux: server",
657            "tmate",
658            "tmate: server",
659            "screen",
660            "SCREEN",
661        ] {
662            assert!(is_multiplexer(name), "expected match for {:?}", name);
663        }
664    }
665
666    #[test]
667    fn is_multiplexer_rejects_non_multiplexers() {
668        for name in [
669            "",
670            "zsh",
671            "bash",
672            "claude",
673            "tmuxinator",
674            "ztmux",
675            "zellij-server",
676            "Screen",
677        ] {
678            assert!(!is_multiplexer(name), "expected no match for {:?}", name);
679        }
680    }
681
682    #[test]
683    fn self_tree_contains_current_pid() {
684        let tree = self_process_tree();
685        assert!(tree.contains(&std::process::id()));
686    }
687
688    #[cfg(unix)]
689    #[test]
690    fn self_tree_contains_parent_pid() {
691        let tree = self_process_tree();
692        let ppid = unsafe { libc::getppid() } as u32;
693        assert!(
694            tree.contains(&ppid),
695            "expected tree to contain ppid {}",
696            ppid
697        );
698    }
699
700    #[test]
701    fn detect_busy_tiered_returns_hard_for_lockfile() {
702        use std::process::{Command, Stdio};
703        let dir = tempfile::tempdir().unwrap();
704        // Mark a fake .git dir so lock_path resolves predictably.
705        let git_dir = dir.path().join(".git");
706        std::fs::create_dir_all(&git_dir).unwrap();
707        // Spawn a short-lived child process to get a live PID that is NOT in
708        // self_process_tree (which excludes all ancestors up to init, but NOT
709        // descendants). Use sleep so the child stays alive through the assertion.
710        let mut child = Command::new("sleep")
711            .arg("30")
712            .stdout(Stdio::null())
713            .stderr(Stdio::null())
714            .spawn()
715            .expect("spawn sleep");
716        let child_pid = child.id();
717        // Write the lockfile directly with the child's PID so it is live and
718        // not excluded by the ancestor-chain filter.
719        let entry = crate::operations::lockfile::LockEntry {
720            version: crate::operations::lockfile::LOCK_VERSION,
721            pid: child_pid,
722            started_at: 0,
723            cmd: "claude".to_string(),
724        };
725        std::fs::write(
726            git_dir.join("gw-session.lock"),
727            serde_json::to_string(&entry).unwrap(),
728        )
729        .unwrap();
730        let (hard, _soft) = detect_busy_tiered(dir.path());
731        let _ = child.kill();
732        let _ = child.wait();
733        assert!(hard
734            .iter()
735            .any(|b| matches!(b.source, BusySource::Lockfile)));
736        assert!(hard.iter().all(|b| matches!(b.tier, BusyTier::Hard)));
737    }
738
739    #[cfg(any(target_os = "linux", target_os = "macos"))]
740    #[test]
741    fn scan_cwd_finds_child_with_cwd_in_tempdir() {
742        use std::process::{Command, Stdio};
743        use std::thread::sleep;
744        use std::time::{Duration, Instant};
745
746        let dir = tempfile::TempDir::new().unwrap();
747        let mut child = Command::new("sleep")
748            .arg("30")
749            .current_dir(dir.path())
750            .stdout(Stdio::null())
751            .stderr(Stdio::null())
752            .spawn()
753            .expect("spawn sleep");
754
755        // Give the OS a beat to register the child's cwd so the first scan
756        // usually succeeds; then fall back to polling for slow CI hosts.
757        // raw_cwd_scan() bypasses the module-static cache (which may have
758        // been populated before the child existed).
759        sleep(Duration::from_millis(50));
760        let canon = dir
761            .path()
762            .canonicalize()
763            .unwrap_or(dir.path().to_path_buf());
764        let matches = |raw: &[(u32, String, std::path::PathBuf)]| -> bool {
765            raw.iter()
766                .any(|(p, _, cwd)| *p == child.id() && cwd.starts_with(&canon))
767        };
768        let mut found = matches(&raw_cwd_scan());
769        if !found {
770            let deadline = Instant::now() + Duration::from_secs(2);
771            while Instant::now() < deadline {
772                if matches(&raw_cwd_scan()) {
773                    found = true;
774                    break;
775                }
776                sleep(Duration::from_millis(50));
777            }
778        }
779
780        let _ = child.kill();
781        let _ = child.wait();
782
783        assert!(
784            found,
785            "expected to find child pid={} with cwd in {:?}",
786            child.id(),
787            dir.path()
788        );
789    }
790
791    /// Regression: a stale jsonl with a recent timestamp but no live
792    /// `claude` process owning the worktree must NOT produce a Hard
793    /// ClaudeSession signal. This is the "user just exited Claude
794    /// cleanly, then ran cw delete" scenario from the bug report.
795    ///
796    /// We exercise it by pointing `$HOME` at a tempdir, planting a
797    /// realistic jsonl under `~/.claude/projects/<encoded>/`, and
798    /// confirming the test process (which does not look like a Claude
799    /// install via its txt mappings) does not satisfy the live-process
800    /// gate.
801    #[cfg(any(target_os = "linux", target_os = "macos"))]
802    #[test]
803    fn detect_busy_tiered_no_hard_when_jsonl_active_but_no_live_claude() {
804        use crate::operations::test_env::{env_lock, EnvGuard};
805        let _lock = env_lock();
806        let _guard = EnvGuard::capture(&["HOME"]);
807
808        let home = tempfile::tempdir().unwrap();
809        std::env::set_var("HOME", home.path());
810
811        let wt = tempfile::tempdir().unwrap();
812        let wt_canon = wt.path().canonicalize().unwrap_or(wt.path().to_path_buf());
813
814        // Encode the worktree path the way Claude Code does (see
815        // claude_session::encode_project_dir).
816        let encoded = wt_canon.to_string_lossy().replace(['/', '.'], "-");
817        let proj_dir = home.path().join(".claude").join("projects").join(encoded);
818        std::fs::create_dir_all(&proj_dir).unwrap();
819
820        // Plant a jsonl whose newest event is "now" — i.e. well within the
821        // 10-minute activity threshold — and whose `cwd` matches the
822        // worktree (otherwise find_active_sessions filters it out).
823        let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
824        let line = serde_json::json!({
825            "timestamp": now,
826            "cwd": wt_canon.to_string_lossy(),
827        });
828        std::fs::write(
829            proj_dir.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
830            format!("{}\n", line),
831        )
832        .unwrap();
833
834        let (hard, _soft) = detect_busy_tiered(wt.path());
835        assert!(
836            !hard
837                .iter()
838                .any(|b| matches!(b.source, BusySource::ClaudeSession)),
839            "expected no Hard ClaudeSession when no live claude holds the worktree, got: {:?}",
840            hard
841        );
842    }
843}