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
48pub 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
57pub 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 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 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 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
118pub 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 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
147pub 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
160pub 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
168pub 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
191pub 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
199pub 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
209pub fn chrono_now_iso_pub() -> String {
211 chrono_now_iso()
212}
213
214fn 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 let secs = now.as_secs();
222 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 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}