1use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13use super::{GitOutput, run_git};
14
15const FIELD: char = '\u{1f}'; #[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Checkpoint {
20 pub sha: String,
21 pub time: String,
23 pub message: String,
24 pub files_changed: Option<usize>,
26}
27
28pub(crate) fn init(project: &Path) -> Result<(), String> {
30 let git_dir = shadow_git_dir(project)?;
31 if git_dir.join("HEAD").exists() {
32 return Ok(());
33 }
34 std::fs::create_dir_all(&git_dir).map_err(|e| format!("cannot create shadow dir: {e}"))?;
35
36 let env = base_env(&git_dir, project);
37 git(&["init", "-q"], project, Duration::from_secs(15), &env)?.ok_stdout()?;
38
39 let excludes = write_excludes(&git_dir)?;
42 let exc = excludes.to_string_lossy();
43 for (k, v) in [
44 ("core.excludesFile", exc.as_ref()),
45 ("commit.gpgsign", "false"),
46 ("gc.auto", "0"),
47 ("core.autocrlf", "false"),
51 ("core.safecrlf", "false"),
52 ] {
53 git(&["config", k, v], project, Duration::from_secs(10), &env)?.ok_stdout()?;
54 }
55 Ok(())
56}
57
58pub fn snapshot(project: &Path, message: &str) -> Result<Checkpoint, String> {
61 init(project)?;
62 let git_dir = shadow_git_dir(project)?;
63 let env = base_env(&git_dir, project);
64
65 git(&["add", "-A"], project, Duration::from_mins(2), &env)?.ok_stdout()?;
66
67 let msg = if message.trim().is_empty() {
68 "lean-ctx checkpoint"
69 } else {
70 message
71 };
72 let commit = git(
73 &["commit", "--no-verify", "-q", "-m", msg],
74 project,
75 Duration::from_mins(1),
76 &env,
77 )?;
78
79 if commit.success {
80 let sha = head_sha(project, &env)?;
81 let files = count_changed(project, &env, &sha);
82 return Ok(Checkpoint {
83 sha,
84 time: now_iso(project, &env),
85 message: msg.to_string(),
86 files_changed: Some(files),
87 });
88 }
89
90 if let Ok(sha) = head_sha(project, &env) {
92 return Ok(Checkpoint {
93 sha,
94 time: now_iso(project, &env),
95 message: "(no changes since last checkpoint)".to_string(),
96 files_changed: Some(0),
97 });
98 }
99 commit.ok_stdout()?;
100 Err("git commit failed".to_string())
101}
102
103pub fn log(project: &Path, limit: usize) -> Result<Vec<Checkpoint>, String> {
105 let git_dir = shadow_git_dir(project)?;
106 if !git_dir.join("HEAD").exists() {
107 return Ok(Vec::new());
108 }
109 let env = base_env(&git_dir, project);
110 let fmt = format!("--pretty=format:%H{FIELD}%cI{FIELD}%s");
111 let out = git(
112 &["log", &fmt, &format!("--max-count={}", limit.max(1))],
113 project,
114 Duration::from_secs(15),
115 &env,
116 )?;
117 if !out.success {
118 return Ok(Vec::new()); }
120 Ok(out
121 .stdout
122 .lines()
123 .filter_map(|line| {
124 let mut parts = line.splitn(3, FIELD);
125 Some(Checkpoint {
126 sha: parts.next()?.to_string(),
127 time: parts.next().unwrap_or("").to_string(),
128 message: parts.next().unwrap_or("").to_string(),
129 files_changed: None,
130 })
131 })
132 .collect())
133}
134
135pub fn diff(project: &Path, from: Option<&str>, to: Option<&str>) -> Result<String, String> {
138 let git_dir = shadow_git_dir(project)?;
139 if !git_dir.join("HEAD").exists() {
140 return Err("no checkpoints yet — run snapshot first".to_string());
141 }
142 let env = base_env(&git_dir, project);
143 let mut args: Vec<String> = vec!["diff".to_string()];
144 match (from, to) {
145 (Some(f), Some(t)) => {
146 args.push(f.to_string());
147 args.push(t.to_string());
148 }
149 (Some(f), None) => args.push(f.to_string()),
150 (None, _) => args.push("HEAD".to_string()),
151 }
152 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
153 git(&arg_refs, project, Duration::from_secs(30), &env)?.ok_stdout()
154}
155
156pub fn restore(project: &Path, git_ref: &str, path: Option<&str>) -> Result<String, String> {
159 let git_dir = shadow_git_dir(project)?;
160 if !git_dir.join("HEAD").exists() {
161 return Err("no checkpoints yet — nothing to restore".to_string());
162 }
163 let env = base_env(&git_dir, project);
164 let target = path.unwrap_or(".");
165 git(
166 &["checkout", git_ref, "--", target],
167 project,
168 Duration::from_mins(1),
169 &env,
170 )?
171 .ok_stdout()?;
172 Ok(format!("restored {target} from {git_ref}"))
173}
174
175pub fn git(
178 args: &[&str],
179 cwd: &Path,
180 timeout: Duration,
181 env: &[(String, String)],
182) -> Result<GitOutput, String> {
183 let refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
184 run_git(args, cwd, timeout, &refs)
185}
186
187fn base_env(git_dir: &Path, work_tree: &Path) -> Vec<(String, String)> {
188 vec![
189 ("GIT_DIR".into(), git_dir.to_string_lossy().into_owned()),
190 (
191 "GIT_WORK_TREE".into(),
192 work_tree.to_string_lossy().into_owned(),
193 ),
194 ("GIT_AUTHOR_NAME".into(), "lean-ctx".into()),
195 ("GIT_AUTHOR_EMAIL".into(), "agent@lean-ctx.local".into()),
196 ("GIT_COMMITTER_NAME".into(), "lean-ctx".into()),
197 ("GIT_COMMITTER_EMAIL".into(), "agent@lean-ctx.local".into()),
198 ]
199}
200
201fn head_sha(project: &Path, env: &[(String, String)]) -> Result<String, String> {
202 let out = git(
203 &["rev-parse", "--short", "HEAD"],
204 project,
205 Duration::from_secs(10),
206 env,
207 )?
208 .ok_stdout()?;
209 Ok(out.trim().to_string())
210}
211
212fn now_iso(project: &Path, env: &[(String, String)]) -> String {
213 git(
214 &["show", "-s", "--format=%cI", "HEAD"],
215 project,
216 Duration::from_secs(10),
217 env,
218 )
219 .ok()
220 .and_then(|o| o.ok_stdout().ok())
221 .map(|s| s.trim().to_string())
222 .unwrap_or_default()
223}
224
225fn count_changed(project: &Path, env: &[(String, String)], sha: &str) -> usize {
226 let range = format!("{sha}^..{sha}");
228 let out = git(
229 &["diff", "--name-only", &range],
230 project,
231 Duration::from_secs(15),
232 env,
233 );
234 match out {
235 Ok(o) if o.success => o.stdout.lines().filter(|l| !l.trim().is_empty()).count(),
236 _ => {
237 git(
239 &["show", "--name-only", "--pretty=format:", sha],
240 project,
241 Duration::from_secs(15),
242 env,
243 )
244 .ok()
245 .and_then(|o| o.ok_stdout().ok())
246 .map_or(0, |s| s.lines().filter(|l| !l.trim().is_empty()).count())
247 }
248 }
249}
250
251fn shadow_git_dir(project: &Path) -> Result<PathBuf, String> {
252 let hash = project_hash(project);
253 Ok(crate::core::data_dir::lean_ctx_data_dir()?
254 .join("shadow")
255 .join(hash)
256 .join("git"))
257}
258
259fn project_hash(project: &Path) -> String {
261 let canonical = std::fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
262 let bytes = canonical.to_string_lossy();
263 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
264 for b in bytes.as_bytes() {
265 hash ^= u64::from(*b);
266 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
267 }
268 format!("{hash:016x}")
269}
270
271fn write_excludes(git_dir: &Path) -> Result<PathBuf, String> {
272 let path = git_dir
273 .parent()
274 .unwrap_or(git_dir)
275 .join("lean-ctx-excludes");
276 let defaults = "\
277# lean-ctx shadow-history excludes (keeps snapshots lean even without a project .gitignore)
278.git/
279target/
280node_modules/
281dist/
282build/
283.venv/
284__pycache__/
285*.lock
286";
287 std::fs::write(&path, defaults).map_err(|e| format!("cannot write excludes: {e}"))?;
288 Ok(path)
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 fn git_ready() -> bool {
296 super::super::git_available()
297 }
298
299 fn temp_project(tag: &str) -> PathBuf {
300 let dir = std::env::temp_dir().join(format!("lc_shadow_{tag}_{}", std::process::id()));
301 let _ = std::fs::remove_dir_all(&dir);
302 std::fs::create_dir_all(&dir).unwrap();
303 dir
304 }
305
306 #[test]
307 fn project_hash_is_stable_and_hex() {
308 let h1 = project_hash(Path::new("/some/project"));
309 let h2 = project_hash(Path::new("/some/project"));
310 assert_eq!(h1, h2);
311 assert_eq!(h1.len(), 16);
312 assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
313 }
314
315 #[test]
316 fn snapshot_log_diff_restore_roundtrip() {
317 if !git_ready() {
318 return;
319 }
320 let _lock = crate::core::data_dir::test_env_lock();
321 let data = temp_project("data");
322 crate::test_env::set_var("LEAN_CTX_DATA_DIR", &data);
323 let project = temp_project("proj");
324
325 std::fs::write(project.join("a.txt"), "v1\n").unwrap();
326 let c1 = snapshot(&project, "first").expect("snapshot 1");
327 assert_eq!(c1.files_changed, Some(1));
328
329 let c1b = snapshot(&project, "again").expect("snapshot noop");
331 assert_eq!(c1b.files_changed, Some(0));
332
333 std::fs::write(project.join("a.txt"), "v2\n").unwrap();
334 let d = diff(&project, None, None).expect("diff vs HEAD");
335 assert!(d.contains("-v1") && d.contains("+v2"), "diff was: {d}");
336
337 let c2 = snapshot(&project, "second").expect("snapshot 2");
338 assert_ne!(c1.sha, c2.sha);
339
340 let entries = log(&project, 10).expect("log");
341 assert!(entries.len() >= 2, "expected >=2 checkpoints");
342
343 restore(&project, &c1.sha, Some("a.txt")).expect("restore");
345 let restored = std::fs::read_to_string(project.join("a.txt")).unwrap();
346 assert_eq!(restored, "v1\n");
347
348 assert!(
350 !project.join(".git").exists(),
351 "shadow history must not touch the user's project .git"
352 );
353
354 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
355 let _ = std::fs::remove_dir_all(&data);
356 let _ = std::fs::remove_dir_all(&project);
357 }
358}