git_worktree_manager/operations/
claude_process.rs1use std::path::{Path, PathBuf};
22use std::sync::OnceLock;
23
24#[derive(Debug, Clone)]
26pub(crate) struct ProcessSnapshot {
27 #[allow(dead_code)]
31 pub pid: u32,
32 pub cwd: Option<PathBuf>,
34 pub txt_paths: Vec<PathBuf>,
38 pub claude_fd_paths: Vec<PathBuf>,
41}
42
43pub(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
56pub(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
74static SNAPSHOT_CACHE: OnceLock<Vec<ProcessSnapshot>> = OnceLock::new();
76
77fn snapshot() -> &'static [ProcessSnapshot] {
78 SNAPSHOT_CACHE.get_or_init(scan_processes).as_slice()
79}
80
81pub(crate) fn prewarm() {
87 let _ = snapshot();
88}
89
90pub 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 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 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 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 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 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 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 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 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 let s = snap(1, Some("/wt-other/sub"), &[], &[]);
355 assert!(!process_holds_worktree(&s, Path::new("/wt")));
356 }
357}