Skip to main content

git_worktree_manager/
session.rs

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