Skip to main content

git_worktree_manager/operations/
claude_session.rs

1//! Hard-tier in-use signal: detects active Claude Code sessions in a
2//! worktree by inspecting `~/.claude/projects/<encoded>/*.jsonl` event tails.
3//!
4//! Encoding rule mirrors Claude Code's own: replace `/` and `.` with `-`,
5//! drop trailing slash. Verified empirically against `~/.claude/projects/`
6//! contents during design.
7
8use std::fs::File;
9use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11
12use chrono::{DateTime, Utc};
13
14/// Encode an absolute filesystem path to the directory name Claude Code
15/// uses under `~/.claude/projects/`. `/` and `.` become `-`. Trailing
16/// path separators are trimmed.
17pub fn encode_project_dir(path: &Path) -> String {
18    let s = path.to_string_lossy();
19    let trimmed = s.trim_end_matches('/');
20    trimmed.replace(['/', '.'], "-")
21}
22
23/// Read up to ~64 KiB from the end of `path`, find the newest line that
24/// parses as JSON with a `timestamp` field, and return that timestamp.
25/// Returns `None` for empty files, files containing only metadata events
26/// without `timestamp`, or unreadable / unparseable files.
27pub fn newest_event_timestamp(path: &Path) -> Option<DateTime<Utc>> {
28    const TAIL_BYTES: u64 = 64 * 1024;
29
30    let mut f = File::open(path).ok()?;
31    let len = f.metadata().ok()?.len();
32    let start = len.saturating_sub(TAIL_BYTES);
33    f.seek(SeekFrom::Start(start)).ok()?;
34
35    let mut buf = Vec::new();
36    f.read_to_end(&mut buf).ok()?;
37
38    // Drop the first (possibly partial) line if we did not start at byte 0.
39    let mut slice = buf.as_slice();
40    if start != 0 {
41        if let Some(nl) = slice.iter().position(|&b| b == b'\n') {
42            slice = &slice[nl + 1..];
43        }
44    }
45
46    let reader = BufReader::new(slice);
47    let mut latest: Option<DateTime<Utc>> = None;
48    for line in reader.lines().map_while(Result::ok) {
49        let trimmed = line.trim();
50        if trimmed.is_empty() {
51            continue;
52        }
53        let v: serde_json::Value = match serde_json::from_str(trimmed) {
54            Ok(v) => v,
55            Err(_) => continue,
56        };
57        let ts_str = match v.get("timestamp").and_then(|x| x.as_str()) {
58            Some(s) => s,
59            None => continue,
60        };
61        let ts = match DateTime::parse_from_rfc3339(ts_str) {
62            Ok(t) => t.with_timezone(&Utc),
63            Err(_) => continue,
64        };
65        match latest {
66            Some(prev) if prev >= ts => {}
67            _ => latest = Some(ts),
68        }
69    }
70    latest
71}
72
73/// Information about one active Claude Code session in a worktree.
74#[derive(Debug, Clone)]
75pub struct ActiveSession {
76    /// jsonl filename without extension (matches Claude session UUID).
77    pub session_id: String,
78    /// Wall-clock time of the most recent event with a `timestamp` field.
79    pub last_activity: DateTime<Utc>,
80}
81
82/// Return all sessions in `project_dir` whose newest event timestamp is
83/// within `threshold` of now AND whose newest event `cwd` (if present)
84/// matches `worktree`. Missing/unreadable directories return an empty vec
85/// — the caller treats this as "Claude not in use here."
86pub fn find_active_sessions(
87    project_dir: &Path,
88    worktree: &Path,
89    threshold: chrono::Duration,
90) -> Vec<ActiveSession> {
91    let entries = match std::fs::read_dir(project_dir) {
92        Ok(e) => e,
93        Err(_) => return Vec::new(),
94    };
95    let now = Utc::now();
96    let wt_canon = worktree
97        .canonicalize()
98        .unwrap_or_else(|_| worktree.to_path_buf());
99    let mut out = Vec::new();
100    for entry in entries.flatten() {
101        let path = entry.path();
102        if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
103            continue;
104        }
105        let Some(ts) = newest_event_timestamp(&path) else {
106            continue;
107        };
108        if (now - ts) > threshold {
109            continue;
110        }
111        if let Some(reported_cwd) = newest_event_cwd(&path) {
112            let reported_canon = reported_cwd.canonicalize().unwrap_or(reported_cwd);
113            if reported_canon != wt_canon {
114                continue;
115            }
116        }
117        let id = path
118            .file_stem()
119            .and_then(|s| s.to_str())
120            .unwrap_or("")
121            .to_string();
122        out.push(ActiveSession {
123            session_id: id,
124            last_activity: ts,
125        });
126    }
127    out
128}
129
130/// Resolve the per-worktree Claude projects directory, e.g.
131/// `~/.claude/projects/-Users-dave-Projects-foo`. Returns `None` if
132/// `$HOME` is not set / cannot be resolved.
133pub fn project_dir_for(worktree: &Path) -> Option<PathBuf> {
134    let home = crate::constants::home_dir_or_fallback();
135    let canon = worktree
136        .canonicalize()
137        .unwrap_or_else(|_| worktree.to_path_buf());
138    let encoded = encode_project_dir(&canon);
139    Some(home.join(".claude").join("projects").join(encoded))
140}
141
142/// Companion: extract the `cwd` field from the same newest event. Used in
143/// Task 4 for path-encoding-collision defense. Returns `None` if not present.
144pub fn newest_event_cwd(path: &Path) -> Option<PathBuf> {
145    const TAIL_BYTES: u64 = 64 * 1024;
146    let mut f = File::open(path).ok()?;
147    let len = f.metadata().ok()?.len();
148    let start = len.saturating_sub(TAIL_BYTES);
149    f.seek(SeekFrom::Start(start)).ok()?;
150    let mut buf = Vec::new();
151    f.read_to_end(&mut buf).ok()?;
152    let mut slice = buf.as_slice();
153    if start != 0 {
154        if let Some(nl) = slice.iter().position(|&b| b == b'\n') {
155            slice = &slice[nl + 1..];
156        }
157    }
158    let reader = BufReader::new(slice);
159    let mut latest: Option<(DateTime<Utc>, PathBuf)> = None;
160    for line in reader.lines().map_while(Result::ok) {
161        let v: serde_json::Value = match serde_json::from_str(line.trim()) {
162            Ok(v) => v,
163            Err(_) => continue,
164        };
165        let Some(ts_str) = v.get("timestamp").and_then(|x| x.as_str()) else {
166            continue;
167        };
168        let Some(cwd_str) = v.get("cwd").and_then(|x| x.as_str()) else {
169            continue;
170        };
171        let Ok(ts) = DateTime::parse_from_rfc3339(ts_str) else {
172            continue;
173        };
174        let ts = ts.with_timezone(&Utc);
175        match latest {
176            Some((prev, _)) if prev >= ts => {}
177            _ => latest = Some((ts, PathBuf::from(cwd_str))),
178        }
179    }
180    latest.map(|(_, p)| p)
181}