Skip to main content

git_worktree_manager/operations/
claude_process.rs

1//! Live Claude Code process detection — confirms a worktree is actually
2//! occupied by a running `claude` instance, not just a stale jsonl event log.
3//!
4//! Background: [`crate::operations::claude_session`] flags a worktree as
5//! "active" when its `~/.claude/projects/<encoded>/*.jsonl` has an event
6//! within the activity threshold. That signal alone is too coarse — when a
7//! user exits Claude Code cleanly the jsonl tail keeps its recent timestamp
8//! and `gw delete` keeps refusing for the full threshold window.
9//!
10//! This module supplies a complementary signal: scan the live process table
11//! for a real `claude` binary (identified by its `txt` mapping under
12//! `~/.local/share/claude/versions/` or the macOS `Claude.app` bundle) whose
13//! `cwd` is the worktree, or that has an open file under
14//! `<worktree>/.claude/`. argv[0] is intentionally not trusted because Claude
15//! Code rewrites it to a version string at runtime.
16//!
17//! The scan is cached in a `OnceLock` for the lifetime of the `gw`
18//! invocation, so callers may invoke `has_live_claude_in` repeatedly without
19//! re-shelling out to `lsof` / re-walking `/proc`.
20
21use std::path::{Path, PathBuf};
22use std::sync::OnceLock;
23
24/// One live process's relevant filesystem state.
25#[derive(Debug, Clone)]
26pub(crate) struct ProcessSnapshot {
27    /// Retained for diagnostics / future filtering, not read by the
28    /// current matchers. `#[allow(dead_code)]` keeps the zero-warning
29    /// policy without losing the field.
30    #[allow(dead_code)]
31    pub pid: u32,
32    /// Process cwd (canonicalized when possible).
33    pub cwd: Option<PathBuf>,
34    /// Paths from `txt`-mapped files (executable + dynamic libraries on
35    /// macOS; main executable on Linux). Used to identify true `claude`
36    /// processes despite argv[0] rewriting.
37    pub txt_paths: Vec<PathBuf>,
38    /// Open regular-file / directory fds. Filtered to paths under any
39    /// `<...>/.claude` to keep the snapshot small.
40    pub claude_fd_paths: Vec<PathBuf>,
41}
42
43/// Predicate: does this snapshot represent a live `claude` process?
44///
45/// Matching rule: any `txt`-mapped path contains either
46/// `/.local/share/claude/versions/` (npm-style installs) or
47/// `/Claude.app/` (macOS desktop app — for completeness; that bundle does
48/// not normally open per-worktree fds, but we keep the rule symmetric).
49pub(crate) fn is_claude_process(snap: &ProcessSnapshot) -> bool {
50    snap.txt_paths.iter().any(|p| {
51        let s = p.to_string_lossy();
52        s.contains("/.local/share/claude/versions/") || s.contains("/Claude.app/")
53    })
54}
55
56/// Predicate: is this process holding the given worktree?
57///
58/// `worktree` must already be canonicalized by the caller (so we don't
59/// canonicalize once per process). Matches when:
60///   * cwd equals worktree or is a descendant, OR
61///   * any `<worktree>/.claude` fd is held open.
62pub(crate) fn process_holds_worktree(snap: &ProcessSnapshot, worktree_canon: &Path) -> bool {
63    if let Some(cwd) = &snap.cwd {
64        if cwd == worktree_canon || cwd.starts_with(worktree_canon) {
65            return true;
66        }
67    }
68    let dot_claude = worktree_canon.join(".claude");
69    snap.claude_fd_paths
70        .iter()
71        .any(|p| p == &dot_claude || p.starts_with(&dot_claude))
72}
73
74/// Cached process snapshot for the lifetime of this `gw` invocation.
75static SNAPSHOT_CACHE: OnceLock<Vec<ProcessSnapshot>> = OnceLock::new();
76
77fn snapshot() -> &'static [ProcessSnapshot] {
78    SNAPSHOT_CACHE.get_or_init(scan_processes).as_slice()
79}
80
81/// Force-populate the snapshot cache. Intended for parallel prewarm: spawn
82/// this on a background thread alongside `busy::prewarm_cwd_scan` so the
83/// two `lsof` calls overlap. Subsequent `has_live_claude_in` calls then
84/// hit the cache. Safe to call from multiple threads — `OnceLock` ensures
85/// the scan runs at most once.
86pub(crate) fn prewarm() {
87    let _ = snapshot();
88}
89
90/// Returns true iff a live `claude` process is occupying `worktree`.
91///
92/// Returns `false` when the scan could not run (lsof missing, /proc
93/// unreadable). The caller should treat that as "no signal available" —
94/// preserving the conservative default of relying on the jsonl-only check.
95pub fn has_live_claude_in(worktree: &Path) -> bool {
96    let canon = worktree
97        .canonicalize()
98        .unwrap_or_else(|_| worktree.to_path_buf());
99    snapshot()
100        .iter()
101        .any(|s| is_claude_process(s) && process_holds_worktree(s, &canon))
102}
103
104#[cfg(target_os = "macos")]
105fn scan_processes() -> Vec<ProcessSnapshot> {
106    use std::collections::HashMap;
107    use std::process::Command;
108
109    // Stage 1: enumerate candidate PIDs via `ps -Ao pid,comm`, filtering on
110    // kernel-recorded command name. This is cheap (~50ms) and avoids the
111    // heavy system-wide `lsof` whose latency balloons under disk/IO load.
112    // `comm` reflects the basename the kernel knows the process by, which
113    // is unaffected by Claude Code's argv[0] rewriting — so this 1st-stage
114    // filter is sound even before the txt-mapping double-check below.
115    let ps_out = match Command::new("ps").args(["-Ao", "pid=,comm="]).output() {
116        Ok(o) if o.status.success() => o,
117        _ => return Vec::new(),
118    };
119    let mut candidate_pids: Vec<u32> = Vec::new();
120    for line in String::from_utf8_lossy(&ps_out.stdout).lines() {
121        let line = line.trim_start();
122        let (pid_s, rest) = match line.split_once(' ') {
123            Some(p) => p,
124            None => continue,
125        };
126        let Ok(pid) = pid_s.parse::<u32>() else {
127            continue;
128        };
129        // `ps -o comm=` on macOS prints the full executable path. Take
130        // basename and match either `claude` (CLI) or `Claude` (Claude.app).
131        let basename = std::path::Path::new(rest.trim())
132            .file_name()
133            .map(|s| s.to_string_lossy().into_owned())
134            .unwrap_or_default();
135        if basename == "claude" || basename == "Claude" {
136            candidate_pids.push(pid);
137        }
138    }
139    if candidate_pids.is_empty() {
140        return Vec::new();
141    }
142
143    // Stage 2: targeted `lsof -p <pid>,<pid>,...` — only the candidates,
144    // not every process on the box. Records: p<pid>, f<fd>, n<path>.
145    let pid_arg = candidate_pids
146        .iter()
147        .map(u32::to_string)
148        .collect::<Vec<_>>()
149        .join(",");
150    let output = match Command::new("lsof")
151        .args(["-F", "pfn", "+c", "0", "-p", &pid_arg])
152        .output()
153    {
154        Ok(o) => o,
155        Err(_) => return Vec::new(),
156    };
157    if !output.status.success() && output.stdout.is_empty() {
158        return Vec::new();
159    }
160
161    let stdout = String::from_utf8_lossy(&output.stdout);
162    let mut by_pid: HashMap<u32, ProcessSnapshot> = HashMap::new();
163    let mut cur_pid: Option<u32> = None;
164    let mut cur_fd = String::new();
165    for line in stdout.lines() {
166        if let Some(rest) = line.strip_prefix('p') {
167            cur_pid = rest.parse().ok();
168            cur_fd.clear();
169        } else if let Some(rest) = line.strip_prefix('f') {
170            cur_fd = rest.to_string();
171        } else if let Some(rest) = line.strip_prefix('n') {
172            let Some(pid) = cur_pid else { continue };
173            let path = PathBuf::from(rest);
174            let entry = by_pid.entry(pid).or_insert_with(|| ProcessSnapshot {
175                pid,
176                cwd: None,
177                txt_paths: Vec::new(),
178                claude_fd_paths: Vec::new(),
179            });
180            match cur_fd.as_str() {
181                "cwd" => {
182                    let canon = path.canonicalize().unwrap_or(path);
183                    entry.cwd = Some(canon);
184                }
185                "txt" => entry.txt_paths.push(path),
186                _ => {
187                    // Keep only fds under some ".claude" directory — these
188                    // are the per-worktree settings.json / .claude/ dir
189                    // that Claude Code holds open while running.
190                    if path.to_string_lossy().contains("/.claude") {
191                        let canon = path.canonicalize().unwrap_or(path);
192                        entry.claude_fd_paths.push(canon);
193                    }
194                }
195            }
196        }
197    }
198    by_pid.into_values().collect()
199}
200
201#[cfg(target_os = "linux")]
202fn scan_processes() -> Vec<ProcessSnapshot> {
203    let proc_dir = match std::fs::read_dir("/proc") {
204        Ok(d) => d,
205        Err(_) => return Vec::new(),
206    };
207    let mut out = Vec::new();
208    for entry in proc_dir.flatten() {
209        let name = entry.file_name();
210        let name = name.to_string_lossy();
211        let pid: u32 = match name.parse() {
212            Ok(n) => n,
213            Err(_) => continue,
214        };
215        let proc_path = entry.path();
216
217        // 1st-stage filter: kernel-recorded comm. Cheap read on every
218        // process; skips the expensive fd directory walk for non-claude
219        // processes (the box typically has hundreds of those).
220        // /proc/<pid>/comm is truncated to 15 chars but "claude" / "Claude"
221        // both fit comfortably.
222        let comm = match std::fs::read_to_string(proc_path.join("comm")) {
223            Ok(s) => s.trim().to_string(),
224            Err(_) => continue,
225        };
226        if comm != "claude" && comm != "Claude" {
227            continue;
228        }
229
230        let cwd = std::fs::read_link(proc_path.join("cwd"))
231            .ok()
232            .map(|p| p.canonicalize().unwrap_or(p));
233        // exe is the main executable (Linux equivalent of macOS lsof's txt
234        // record). Library mappings live in /proc/<pid>/maps; we don't need
235        // them — exe alone suffices to identify a `claude` binary.
236        let mut txt_paths = Vec::new();
237        if let Ok(exe) = std::fs::read_link(proc_path.join("exe")) {
238            txt_paths.push(exe);
239        }
240        let mut claude_fd_paths = Vec::new();
241        if let Ok(fds) = std::fs::read_dir(proc_path.join("fd")) {
242            for fd_entry in fds.flatten() {
243                if let Ok(target) = std::fs::read_link(fd_entry.path()) {
244                    if target.to_string_lossy().contains("/.claude") {
245                        let canon = target.canonicalize().unwrap_or(target);
246                        claude_fd_paths.push(canon);
247                    }
248                }
249            }
250        }
251        out.push(ProcessSnapshot {
252            pid,
253            cwd,
254            txt_paths,
255            claude_fd_paths,
256        });
257    }
258    out
259}
260
261#[cfg(not(any(target_os = "macos", target_os = "linux")))]
262fn scan_processes() -> Vec<ProcessSnapshot> {
263    // No portable way to enumerate process cwds + open fds on Windows
264    // without extra deps. Returning empty disables the live-process check —
265    // behavior falls back to the jsonl-only signal, which matches current
266    // (pre-this-module) behavior on Windows.
267    Vec::new()
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn snap(pid: u32, cwd: Option<&str>, txt: &[&str], fds: &[&str]) -> ProcessSnapshot {
275        ProcessSnapshot {
276            pid,
277            cwd: cwd.map(PathBuf::from),
278            txt_paths: txt.iter().map(PathBuf::from).collect(),
279            claude_fd_paths: fds.iter().map(PathBuf::from).collect(),
280        }
281    }
282
283    #[test]
284    fn is_claude_process_detects_user_install() {
285        let s = snap(
286            1,
287            Some("/wt"),
288            &["/Users/dave/.local/share/claude/versions/2.1.121"],
289            &[],
290        );
291        assert!(is_claude_process(&s));
292    }
293
294    #[test]
295    fn is_claude_process_detects_macos_desktop_app() {
296        let s = snap(
297            1,
298            Some("/wt"),
299            &["/Applications/Claude.app/Contents/MacOS/Claude"],
300            &[],
301        );
302        assert!(is_claude_process(&s));
303    }
304
305    #[test]
306    fn is_claude_process_rejects_unrelated_binary() {
307        let s = snap(1, Some("/wt"), &["/usr/bin/zsh"], &[]);
308        assert!(!is_claude_process(&s));
309    }
310
311    #[test]
312    fn is_claude_process_rejects_substring_outside_claude_path() {
313        // Defensive: a `claude` substring outside the recognized install
314        // patterns must not match. (`/etc/claude/foo` is not a Claude Code
315        // installation.)
316        let s = snap(1, Some("/wt"), &["/etc/claude/foo"], &[]);
317        assert!(!is_claude_process(&s));
318    }
319
320    #[test]
321    fn process_holds_worktree_matches_exact_cwd() {
322        let s = snap(1, Some("/wt"), &[], &[]);
323        assert!(process_holds_worktree(&s, Path::new("/wt")));
324    }
325
326    #[test]
327    fn process_holds_worktree_matches_descendant_cwd() {
328        let s = snap(1, Some("/wt/subdir/inner"), &[], &[]);
329        assert!(process_holds_worktree(&s, Path::new("/wt")));
330    }
331
332    #[test]
333    fn process_holds_worktree_matches_dot_claude_fd() {
334        let s = snap(1, Some("/elsewhere"), &[], &["/wt/.claude/settings.json"]);
335        assert!(process_holds_worktree(&s, Path::new("/wt")));
336    }
337
338    #[test]
339    fn process_holds_worktree_rejects_unrelated_cwd_and_fds() {
340        let s = snap(
341            1,
342            Some("/elsewhere"),
343            &[],
344            &["/Users/dave/.claude/settings.json"],
345        );
346        assert!(!process_holds_worktree(&s, Path::new("/wt")));
347    }
348
349    #[test]
350    fn process_holds_worktree_rejects_sibling_with_shared_prefix() {
351        // /wt-other should not match /wt — Path::starts_with is component-
352        // wise so this is already safe, but pin it down with a test so a
353        // future refactor to string-prefix matching gets caught.
354        let s = snap(1, Some("/wt-other/sub"), &[], &[]);
355        assert!(!process_holds_worktree(&s, Path::new("/wt")));
356    }
357}