Skip to main content

git_worktree_manager/
session.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::constants::{
6    home_dir_or_fallback, sanitize_branch_name, CLAUDE_SESSION_PREFIX_LENGTH, SECS_PER_DAY,
7};
8use crate::error::Result;
9use crate::git::normalize_branch_name;
10
11/// Session metadata.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SessionMetadata {
14    pub branch: String,
15    pub ai_tool: String,
16    pub worktree_path: String,
17    pub created_at: String,
18    pub updated_at: String,
19}
20
21/// Get the base sessions directory.
22/// Falls back to legacy Python path if the new path doesn't exist.
23pub fn get_sessions_dir() -> PathBuf {
24    let home = home_dir_or_fallback();
25    let new_dir = home
26        .join(".config")
27        .join("git-worktree-manager")
28        .join("sessions");
29
30    if new_dir.exists() {
31        return new_dir;
32    }
33
34    let legacy_dir = home
35        .join(".config")
36        .join("claude-worktree")
37        .join("sessions");
38
39    if legacy_dir.exists() {
40        return legacy_dir;
41    }
42
43    // Default to new path and create it
44    let _ = std::fs::create_dir_all(&new_dir);
45    new_dir
46}
47
48/// Compute the session directory path for a specific branch without creating it.
49fn session_dir_path(branch_name: &str) -> PathBuf {
50    let branch = normalize_branch_name(branch_name);
51    let safe = sanitize_branch_name(branch);
52    get_sessions_dir().join(safe)
53}
54
55/// Get the session directory for a specific branch, creating it if needed.
56pub fn get_session_dir(branch_name: &str) -> PathBuf {
57    let dir = session_dir_path(branch_name);
58    let _ = std::fs::create_dir_all(&dir);
59    dir
60}
61
62/// Check if a Claude native session exists for the given worktree path.
63pub fn claude_native_session_exists(worktree_path: &Path) -> bool {
64    let path_str = worktree_path
65        .canonicalize()
66        .unwrap_or_else(|_| worktree_path.to_path_buf())
67        .to_string_lossy()
68        .to_string();
69
70    // Encode path: replace non-alphanumeric with hyphens
71    let encoded: String = path_str
72        .chars()
73        .map(|c| if c.is_alphanumeric() { c } else { '-' })
74        .collect();
75
76    let claude_projects_dir = home_dir_or_fallback().join(".claude").join("projects");
77
78    if !claude_projects_dir.exists() {
79        return false;
80    }
81
82    // Direct match
83    if encoded.len() <= 255 {
84        let project_dir = claude_projects_dir.join(&encoded);
85        if project_dir.is_dir() && has_jsonl_files(&project_dir) {
86            return true;
87        }
88    }
89
90    // Prefix matching for long paths
91    if encoded.len() > CLAUDE_SESSION_PREFIX_LENGTH {
92        let prefix = &encoded[..CLAUDE_SESSION_PREFIX_LENGTH];
93        if let Ok(entries) = std::fs::read_dir(&claude_projects_dir) {
94            for entry in entries.flatten() {
95                let name = entry.file_name();
96                let name_str = name.to_string_lossy();
97                if entry.path().is_dir()
98                    && name_str.starts_with(prefix)
99                    && has_jsonl_files(&entry.path())
100                {
101                    return true;
102                }
103            }
104        }
105    }
106
107    false
108}
109
110fn has_jsonl_files(dir: &Path) -> bool {
111    std::fs::read_dir(dir)
112        .map(|entries| {
113            entries.flatten().any(|e| {
114                e.path()
115                    .extension()
116                    .map(|ext| ext == "jsonl")
117                    .unwrap_or(false)
118            })
119        })
120        .unwrap_or(false)
121}
122
123/// Save session metadata for a branch.
124///
125/// Uses an atomic temp-then-rename write so that a concurrent `gw resume` on
126/// the same branch never sees a partially-written file. The rename is atomic
127/// on POSIX and best-effort on Windows (falls back to a direct write if the
128/// temp file ends up on a different filesystem than the metadata directory,
129/// which is impossible in practice since both live under `~/.config/`).
130pub fn save_session_metadata(branch_name: &str, ai_tool: &str, worktree_path: &str) -> Result<()> {
131    let session_dir = get_session_dir(branch_name);
132    let metadata_file = session_dir.join("metadata.json");
133
134    let now = chrono_now_iso();
135
136    let mut metadata = SessionMetadata {
137        branch: branch_name.to_string(),
138        ai_tool: ai_tool.to_string(),
139        worktree_path: worktree_path.to_string(),
140        created_at: now.clone(),
141        updated_at: now,
142    };
143
144    // Preserve created_at if metadata already exists. Read before acquiring
145    // the write slot so we don't need to hold a lock across the read.
146    if let Ok(content) = std::fs::read_to_string(&metadata_file) {
147        if let Ok(existing) = serde_json::from_str::<SessionMetadata>(&content) {
148            metadata.created_at = existing.created_at;
149        }
150    }
151
152    let content = serde_json::to_string_pretty(&metadata)?;
153    // Write to a sibling temp file then atomically rename into place so
154    // concurrent readers never see a half-written file.
155    let tmp = metadata_file.with_extension("json.tmp");
156    std::fs::write(&tmp, &content)?;
157    std::fs::rename(&tmp, &metadata_file)?;
158    Ok(())
159}
160
161/// Load session metadata for a branch.
162pub fn load_session_metadata(branch_name: &str) -> Option<SessionMetadata> {
163    let session_dir = session_dir_path(branch_name);
164    let metadata_file = session_dir.join("metadata.json");
165
166    if !metadata_file.exists() {
167        return None;
168    }
169
170    let content = std::fs::read_to_string(&metadata_file).ok()?;
171    serde_json::from_str(&content).ok()
172}
173
174/// Delete all session data for a branch.
175pub fn delete_session(branch_name: &str) {
176    let session_dir = get_session_dir(branch_name);
177    if session_dir.exists() {
178        let _ = std::fs::remove_dir_all(session_dir);
179    }
180}
181
182/// List all saved sessions.
183pub fn list_sessions() -> Vec<SessionMetadata> {
184    let sessions_dir = get_sessions_dir();
185    let mut sessions = Vec::new();
186
187    if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
188        for entry in entries.flatten() {
189            if entry.path().is_dir() {
190                let metadata_file = entry.path().join("metadata.json");
191                if metadata_file.exists() {
192                    if let Ok(content) = std::fs::read_to_string(&metadata_file) {
193                        if let Ok(meta) = serde_json::from_str::<SessionMetadata>(&content) {
194                            sessions.push(meta);
195                        }
196                    }
197                }
198            }
199        }
200    }
201
202    sessions
203}
204
205/// Save context information for a branch.
206pub fn save_context(branch_name: &str, context: &str) -> Result<()> {
207    let session_dir = get_session_dir(branch_name);
208    let context_file = session_dir.join("context.txt");
209    std::fs::write(&context_file, context)?;
210    Ok(())
211}
212
213/// Load context information for a branch.
214pub fn load_context(branch_name: &str) -> Option<String> {
215    let session_dir = session_dir_path(branch_name);
216    let context_file = session_dir.join("context.txt");
217    if !context_file.exists() {
218        return None;
219    }
220    std::fs::read_to_string(&context_file).ok()
221}
222
223/// Public accessor for the ISO timestamp function.
224pub fn chrono_now_iso_pub() -> String {
225    chrono_now_iso()
226}
227
228/// Simple ISO timestamp without chrono dependency.
229fn chrono_now_iso() -> String {
230    use std::time::SystemTime;
231    let now = SystemTime::now()
232        .duration_since(SystemTime::UNIX_EPOCH)
233        .unwrap_or_default();
234    // Rough ISO format — sufficient for metadata
235    let secs = now.as_secs();
236    // Convert to rough UTC datetime
237    let days = secs / SECS_PER_DAY;
238    let time_secs = secs % SECS_PER_DAY;
239    let hours = time_secs / 3600;
240    let minutes = (time_secs % 3600) / 60;
241    let seconds = time_secs % 60;
242
243    // Approximate date calculation (good enough for metadata)
244    let mut year = 1970u64;
245    let mut remaining_days = days;
246    loop {
247        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
248        if remaining_days < days_in_year {
249            break;
250        }
251        remaining_days -= days_in_year;
252        year += 1;
253    }
254    let mut month = 1u64;
255    let month_days = if is_leap_year(year) {
256        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
257    } else {
258        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
259    };
260    for &md in &month_days {
261        if remaining_days < md {
262            break;
263        }
264        remaining_days -= md;
265        month += 1;
266    }
267    let day = remaining_days + 1;
268
269    format!(
270        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
271        year, month, day, hours, minutes, seconds
272    )
273}
274
275fn is_leap_year(year: u64) -> bool {
276    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
277}