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/// Get the session directory for a specific branch.
49pub fn get_session_dir(branch_name: &str) -> PathBuf {
50    let branch = normalize_branch_name(branch_name);
51    let safe = sanitize_branch_name(branch);
52    let dir = get_sessions_dir().join(safe);
53    let _ = std::fs::create_dir_all(&dir);
54    dir
55}
56
57/// Check if a Claude native session exists for the given worktree path.
58pub fn claude_native_session_exists(worktree_path: &Path) -> bool {
59    let path_str = worktree_path
60        .canonicalize()
61        .unwrap_or_else(|_| worktree_path.to_path_buf())
62        .to_string_lossy()
63        .to_string();
64
65    // Encode path: replace non-alphanumeric with hyphens
66    let encoded: String = path_str
67        .chars()
68        .map(|c| if c.is_alphanumeric() { c } else { '-' })
69        .collect();
70
71    let claude_projects_dir = home_dir_or_fallback().join(".claude").join("projects");
72
73    if !claude_projects_dir.exists() {
74        return false;
75    }
76
77    // Direct match
78    if encoded.len() <= 255 {
79        let project_dir = claude_projects_dir.join(&encoded);
80        if project_dir.is_dir() && has_jsonl_files(&project_dir) {
81            return true;
82        }
83    }
84
85    // Prefix matching for long paths
86    if encoded.len() > CLAUDE_SESSION_PREFIX_LENGTH {
87        let prefix = &encoded[..CLAUDE_SESSION_PREFIX_LENGTH];
88        if let Ok(entries) = std::fs::read_dir(&claude_projects_dir) {
89            for entry in entries.flatten() {
90                let name = entry.file_name();
91                let name_str = name.to_string_lossy();
92                if entry.path().is_dir()
93                    && name_str.starts_with(prefix)
94                    && has_jsonl_files(&entry.path())
95                {
96                    return true;
97                }
98            }
99        }
100    }
101
102    false
103}
104
105fn has_jsonl_files(dir: &Path) -> bool {
106    std::fs::read_dir(dir)
107        .map(|entries| {
108            entries.flatten().any(|e| {
109                e.path()
110                    .extension()
111                    .map(|ext| ext == "jsonl")
112                    .unwrap_or(false)
113            })
114        })
115        .unwrap_or(false)
116}
117
118/// Save session metadata for a branch.
119pub fn save_session_metadata(branch_name: &str, ai_tool: &str, worktree_path: &str) -> Result<()> {
120    let session_dir = get_session_dir(branch_name);
121    let metadata_file = session_dir.join("metadata.json");
122
123    let now = chrono_now_iso();
124
125    let mut metadata = SessionMetadata {
126        branch: branch_name.to_string(),
127        ai_tool: ai_tool.to_string(),
128        worktree_path: worktree_path.to_string(),
129        created_at: now.clone(),
130        updated_at: now,
131    };
132
133    // Preserve created_at if metadata already exists
134    if metadata_file.exists() {
135        if let Ok(content) = std::fs::read_to_string(&metadata_file) {
136            if let Ok(existing) = serde_json::from_str::<SessionMetadata>(&content) {
137                metadata.created_at = existing.created_at;
138            }
139        }
140    }
141
142    let content = serde_json::to_string_pretty(&metadata)?;
143    std::fs::write(&metadata_file, content)?;
144    Ok(())
145}
146
147/// Load session metadata for a branch.
148pub fn load_session_metadata(branch_name: &str) -> Option<SessionMetadata> {
149    let session_dir = get_session_dir(branch_name);
150    let metadata_file = session_dir.join("metadata.json");
151
152    if !metadata_file.exists() {
153        return None;
154    }
155
156    let content = std::fs::read_to_string(&metadata_file).ok()?;
157    serde_json::from_str(&content).ok()
158}
159
160/// Delete all session data for a branch.
161pub fn delete_session(branch_name: &str) {
162    let session_dir = get_session_dir(branch_name);
163    if session_dir.exists() {
164        let _ = std::fs::remove_dir_all(session_dir);
165    }
166}
167
168/// List all saved sessions.
169pub fn list_sessions() -> Vec<SessionMetadata> {
170    let sessions_dir = get_sessions_dir();
171    let mut sessions = Vec::new();
172
173    if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
174        for entry in entries.flatten() {
175            if entry.path().is_dir() {
176                let metadata_file = entry.path().join("metadata.json");
177                if metadata_file.exists() {
178                    if let Ok(content) = std::fs::read_to_string(&metadata_file) {
179                        if let Ok(meta) = serde_json::from_str::<SessionMetadata>(&content) {
180                            sessions.push(meta);
181                        }
182                    }
183                }
184            }
185        }
186    }
187
188    sessions
189}
190
191/// Save context information for a branch.
192pub fn save_context(branch_name: &str, context: &str) -> Result<()> {
193    let session_dir = get_session_dir(branch_name);
194    let context_file = session_dir.join("context.txt");
195    std::fs::write(&context_file, context)?;
196    Ok(())
197}
198
199/// Load context information for a branch.
200pub fn load_context(branch_name: &str) -> Option<String> {
201    let session_dir = get_session_dir(branch_name);
202    let context_file = session_dir.join("context.txt");
203    if !context_file.exists() {
204        return None;
205    }
206    std::fs::read_to_string(&context_file).ok()
207}
208
209/// Public accessor for the ISO timestamp function.
210pub fn chrono_now_iso_pub() -> String {
211    chrono_now_iso()
212}
213
214/// Simple ISO timestamp without chrono dependency.
215fn chrono_now_iso() -> String {
216    use std::time::SystemTime;
217    let now = SystemTime::now()
218        .duration_since(SystemTime::UNIX_EPOCH)
219        .unwrap_or_default();
220    // Rough ISO format — sufficient for metadata
221    let secs = now.as_secs();
222    // Convert to rough UTC datetime
223    let days = secs / SECS_PER_DAY;
224    let time_secs = secs % SECS_PER_DAY;
225    let hours = time_secs / 3600;
226    let minutes = (time_secs % 3600) / 60;
227    let seconds = time_secs % 60;
228
229    // Approximate date calculation (good enough for metadata)
230    let mut year = 1970u64;
231    let mut remaining_days = days;
232    loop {
233        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
234        if remaining_days < days_in_year {
235            break;
236        }
237        remaining_days -= days_in_year;
238        year += 1;
239    }
240    let mut month = 1u64;
241    let month_days = if is_leap_year(year) {
242        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
243    } else {
244        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
245    };
246    for &md in &month_days {
247        if remaining_days < md {
248            break;
249        }
250        remaining_days -= md;
251        month += 1;
252    }
253    let day = remaining_days + 1;
254
255    format!(
256        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
257        year, month, day, hours, minutes, seconds
258    )
259}
260
261fn is_leap_year(year: u64) -> bool {
262    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
263}