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 start` / AI-tool sessions 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 rm` 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 rm`.
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 // SAFETY: getpgid is async-signal-safe and has no side effects.
182 // A return value of -1 indicates an error (pid not found or EPERM).
183 let pgid = unsafe { libc::getpgid(pid as libc::pid_t) };
184 if pgid == -1 {
185 None
186 } else {
187 Some(pgid as u32)
188 }
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 rm` 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 start` session will not be flagged as busy.
452/// Commands that need strong busy guarantees (`gw rm`) continue to
453/// 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 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 rm`, and
499/// `display::get_worktree_status` uses it as the "busy" check for the
500/// read-only `gw list` surface.
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 // the read-only `gw list` surface shares 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 rm` 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}