git_worktree_manager/
session.rs1use 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#[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
21pub 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 let _ = std::fs::create_dir_all(&new_dir);
45 new_dir
46}
47
48fn 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
55pub 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
62pub 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 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 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 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
123pub 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 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 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
161pub 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
174pub 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
182pub 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
205pub 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
213pub 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
223pub fn chrono_now_iso_pub() -> String {
225 chrono_now_iso()
226}
227
228fn 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 let secs = now.as_secs();
236 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 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}