1use std::path::{Path, PathBuf};
17use std::process::Stdio;
18
19use sha2::{Digest, Sha256};
20use tokio::process::Command;
21
22const DEFAULT_EXCLUDES: &[&str] = &[
23 "node_modules/",
24 "dist/",
25 "build/",
26 ".env",
27 ".env.*",
28 ".env.local",
29 ".env.*.local",
30 "__pycache__/",
31 "*.pyc",
32 "*.pyo",
33 ".DS_Store",
34 "*.log",
35 ".cache/",
36 ".next/",
37 ".nuxt/",
38 "coverage/",
39 ".pytest_cache/",
40 ".venv/",
41 "venv/",
42 ".git/",
43 "target/",
44];
45
46const GIT_TIMEOUT_SECS: u64 = 30;
47
48#[derive(Debug, Clone)]
49pub struct Checkpoint {
50 pub commit: String,
51 pub label: String,
52 pub timestamp: String,
53}
54
55pub fn shadow_repo_path(checkpoints_root: &Path, working_dir: &Path) -> PathBuf {
59 let canonical = working_dir
60 .canonicalize()
61 .unwrap_or_else(|_| working_dir.to_path_buf());
62 let mut hasher = Sha256::new();
63 hasher.update(canonical.to_string_lossy().as_bytes());
64 let h = hasher.finalize();
65 let dir_hash = format!("{:x}", h)[..16].to_string();
66 checkpoints_root.join(dir_hash)
67}
68
69pub async fn ensure_shadow_repo(shadow_repo: &Path, working_dir: &Path) -> Result<(), String> {
71 if shadow_repo.join("HEAD").exists() {
72 return Ok(());
73 }
74 if !working_dir.exists() {
75 return Err(format!(
76 "working directory does not exist: {}",
77 working_dir.display()
78 ));
79 }
80 tokio::fs::create_dir_all(shadow_repo)
81 .await
82 .map_err(|e| format!("create shadow dir: {e}"))?;
83
84 run_git(&["init", "--quiet"], shadow_repo, working_dir).await?;
85 run_git(
86 &["config", "user.email", "microclaw@local"],
87 shadow_repo,
88 working_dir,
89 )
90 .await?;
91 run_git(
92 &["config", "user.name", "MicroClaw Checkpoint"],
93 shadow_repo,
94 working_dir,
95 )
96 .await?;
97 run_git(
98 &["config", "commit.gpgsign", "false"],
99 shadow_repo,
100 working_dir,
101 )
102 .await?;
103 run_git(
104 &["config", "tag.gpgSign", "false"],
105 shadow_repo,
106 working_dir,
107 )
108 .await?;
109
110 let info_dir = shadow_repo.join("info");
111 tokio::fs::create_dir_all(&info_dir)
112 .await
113 .map_err(|e| format!("create info dir: {e}"))?;
114 let exclude_body = DEFAULT_EXCLUDES.join("\n") + "\n";
115 tokio::fs::write(info_dir.join("exclude"), exclude_body)
116 .await
117 .map_err(|e| format!("write info/exclude: {e}"))?;
118
119 let workdir_marker = shadow_repo.join("MICROCLAW_WORKDIR");
120 let _ = tokio::fs::write(&workdir_marker, format!("{}\n", working_dir.display())).await;
121 Ok(())
122}
123
124pub async fn snapshot(
127 shadow_repo: &Path,
128 working_dir: &Path,
129 label: &str,
130) -> Result<Option<String>, String> {
131 ensure_shadow_repo(shadow_repo, working_dir).await?;
132 run_git(&["add", "-A"], shadow_repo, working_dir).await?;
133
134 let exit =
136 run_git_status_only(&["diff", "--cached", "--quiet"], shadow_repo, working_dir).await?;
137 if exit == 0 {
138 let (has_head, _, _) =
139 capture_git(&["rev-parse", "--verify", "HEAD"], shadow_repo, working_dir).await?;
140 if has_head {
141 return Ok(None);
142 }
143 run_git(
144 &[
145 "commit",
146 "--quiet",
147 "--allow-empty",
148 "--allow-empty-message",
149 "-m",
150 label,
151 ],
152 shadow_repo,
153 working_dir,
154 )
155 .await?;
156 let (_, stdout, _) = capture_git(&["rev-parse", "HEAD"], shadow_repo, working_dir).await?;
157 return Ok(Some(stdout.trim().to_string()));
158 }
159
160 run_git(
161 &["commit", "--quiet", "--allow-empty-message", "-m", label],
162 shadow_repo,
163 working_dir,
164 )
165 .await?;
166 let (_ok, stdout, _stderr) =
167 capture_git(&["rev-parse", "HEAD"], shadow_repo, working_dir).await?;
168 Ok(Some(stdout.trim().to_string()))
169}
170
171pub async fn list(
173 shadow_repo: &Path,
174 working_dir: &Path,
175 limit: usize,
176) -> Result<Vec<Checkpoint>, String> {
177 if !shadow_repo.join("HEAD").exists() {
178 return Ok(Vec::new());
179 }
180 let limit_str = limit.to_string();
181 let format = "%h\x1f%s\x1f%cI";
182 let (ok, stdout, stderr) = capture_git(
183 &[
184 "log",
185 "-n",
186 &limit_str,
187 &format!("--pretty=format:{format}"),
188 ],
189 shadow_repo,
190 working_dir,
191 )
192 .await?;
193 if !ok {
194 if stderr.contains("does not have any commits") {
195 return Ok(Vec::new());
196 }
197 return Err(format!("git log failed: {stderr}"));
198 }
199 let mut out = Vec::new();
200 for line in stdout.lines() {
201 let mut parts = line.splitn(3, '\x1f');
202 let commit = parts.next().unwrap_or("").to_string();
203 let label = parts.next().unwrap_or("").to_string();
204 let timestamp = parts.next().unwrap_or("").to_string();
205 if !commit.is_empty() {
206 out.push(Checkpoint {
207 commit,
208 label,
209 timestamp,
210 });
211 }
212 }
213 Ok(out)
214}
215
216pub async fn restore(
219 shadow_repo: &Path,
220 working_dir: &Path,
221 commit_hash: &str,
222) -> Result<(), String> {
223 if commit_hash.is_empty() || commit_hash.starts_with('-') {
224 return Err(format!("invalid commit hash: {commit_hash:?}"));
225 }
226 if !commit_hash.chars().all(|c| c.is_ascii_hexdigit()) {
227 return Err(format!(
228 "invalid commit hash (must be 4–64 hex chars): {commit_hash:?}"
229 ));
230 }
231 if commit_hash.len() < 4 || commit_hash.len() > 64 {
232 return Err(format!(
233 "invalid commit hash (must be 4–64 hex chars): {commit_hash:?}"
234 ));
235 }
236 if !shadow_repo.join("HEAD").exists() {
237 return Err("no checkpoint repo exists for this chat".into());
238 }
239 run_git(
243 &["read-tree", "--reset", "-u", commit_hash],
244 shadow_repo,
245 working_dir,
246 )
247 .await?;
248 run_git(&["clean", "-fd"], shadow_repo, working_dir).await?;
252 Ok(())
253}
254
255fn build_command(args: &[&str], shadow_repo: &Path, working_dir: &Path) -> Command {
260 let mut cmd = Command::new("git");
261 cmd.args(args)
262 .current_dir(working_dir)
263 .env("GIT_DIR", shadow_repo)
264 .env("GIT_WORK_TREE", working_dir)
265 .env("GIT_CONFIG_GLOBAL", "/dev/null")
266 .env("GIT_CONFIG_SYSTEM", "/dev/null")
267 .env("GIT_CONFIG_NOSYSTEM", "1")
268 .env_remove("GIT_INDEX_FILE")
269 .env_remove("GIT_NAMESPACE")
270 .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
271 .stdin(Stdio::null())
272 .stdout(Stdio::piped())
273 .stderr(Stdio::piped());
274 cmd
275}
276
277async fn run_git(args: &[&str], shadow_repo: &Path, working_dir: &Path) -> Result<(), String> {
278 let mut cmd = build_command(args, shadow_repo, working_dir);
279 let output = tokio::time::timeout(
280 std::time::Duration::from_secs(GIT_TIMEOUT_SECS),
281 cmd.output(),
282 )
283 .await
284 .map_err(|_| format!("git timed out: {args:?}"))?
285 .map_err(|e| format!("git spawn failed: {e}"))?;
286 if !output.status.success() {
287 return Err(format!(
288 "git {args:?} failed: {}",
289 String::from_utf8_lossy(&output.stderr).trim()
290 ));
291 }
292 Ok(())
293}
294
295async fn run_git_status_only(
296 args: &[&str],
297 shadow_repo: &Path,
298 working_dir: &Path,
299) -> Result<i32, String> {
300 let mut cmd = build_command(args, shadow_repo, working_dir);
301 let output = tokio::time::timeout(
302 std::time::Duration::from_secs(GIT_TIMEOUT_SECS),
303 cmd.output(),
304 )
305 .await
306 .map_err(|_| format!("git timed out: {args:?}"))?
307 .map_err(|e| format!("git spawn failed: {e}"))?;
308 Ok(output.status.code().unwrap_or(-1))
309}
310
311async fn capture_git(
312 args: &[&str],
313 shadow_repo: &Path,
314 working_dir: &Path,
315) -> Result<(bool, String, String), String> {
316 let mut cmd = build_command(args, shadow_repo, working_dir);
317 let output = tokio::time::timeout(
318 std::time::Duration::from_secs(GIT_TIMEOUT_SECS),
319 cmd.output(),
320 )
321 .await
322 .map_err(|_| format!("git timed out: {args:?}"))?
323 .map_err(|e| format!("git spawn failed: {e}"))?;
324 Ok((
325 output.status.success(),
326 String::from_utf8_lossy(&output.stdout).into_owned(),
327 String::from_utf8_lossy(&output.stderr).into_owned(),
328 ))
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn tmp_dir() -> PathBuf {
336 let dir = std::env::temp_dir().join(format!("microclaw_ckpt_{}", uuid::Uuid::new_v4()));
337 std::fs::create_dir_all(&dir).unwrap();
338 dir
339 }
340
341 async fn git_available() -> bool {
342 Command::new("git").arg("--version").output().await.is_ok()
343 }
344
345 #[tokio::test]
346 async fn snapshot_then_list_then_restore_round_trip() {
347 if !git_available().await {
348 eprintln!("skipping: git not installed");
349 return;
350 }
351 let workdir = tmp_dir();
352 let shadow = tmp_dir().join("shadow");
353
354 std::fs::write(workdir.join("a.txt"), "v1").unwrap();
356 let c1 = snapshot(&shadow, &workdir, "round 1").await.unwrap();
357 assert!(c1.is_some(), "first snapshot should make a commit");
358
359 std::fs::write(workdir.join("a.txt"), "v2").unwrap();
361 std::fs::write(workdir.join("added-in-round-2.txt"), "round 2").unwrap();
362 let c2 = snapshot(&shadow, &workdir, "round 2").await.unwrap();
363 assert!(c2.is_some());
364 let c2_hash = c2.unwrap();
365
366 let c3 = snapshot(&shadow, &workdir, "round 3 noop").await.unwrap();
368 assert!(c3.is_none(), "no changes -> no commit");
369
370 let entries = list(&shadow, &workdir, 10).await.unwrap();
372 assert_eq!(entries.len(), 2);
373 assert!(entries[0].label.contains("round 2"));
374 assert!(entries[1].label.contains("round 1"));
375
376 let c1_hash = c1.unwrap();
378 restore(&shadow, &workdir, &c1_hash).await.unwrap();
379 assert_eq!(
380 std::fs::read_to_string(workdir.join("a.txt")).unwrap(),
381 "v1"
382 );
383 assert!(!workdir.join("added-in-round-2.txt").exists());
384
385 std::fs::write(workdir.join("created-after-checkpoint.txt"), "new").unwrap();
386 std::fs::write(workdir.join(".env"), "preserved-secret").unwrap();
387 restore(&shadow, &workdir, &c1_hash).await.unwrap();
388 assert!(!workdir.join("created-after-checkpoint.txt").exists());
389 assert_eq!(
390 std::fs::read_to_string(workdir.join(".env")).unwrap(),
391 "preserved-secret"
392 );
393
394 restore(&shadow, &workdir, &c2_hash).await.unwrap();
396 assert_eq!(
397 std::fs::read_to_string(workdir.join("a.txt")).unwrap(),
398 "v2"
399 );
400 assert_eq!(
401 std::fs::read_to_string(workdir.join("added-in-round-2.txt")).unwrap(),
402 "round 2"
403 );
404
405 let _ = std::fs::remove_dir_all(&workdir);
407 let _ = std::fs::remove_dir_all(shadow.parent().unwrap());
408 }
409
410 #[tokio::test]
411 async fn restore_rejects_invalid_hash() {
412 if !git_available().await {
413 return;
414 }
415 let workdir = tmp_dir();
416 let shadow = tmp_dir().join("shadow");
417 std::fs::write(workdir.join("a.txt"), "v1").unwrap();
418 snapshot(&shadow, &workdir, "init").await.unwrap();
419
420 let err = restore(&shadow, &workdir, "--patch").await.unwrap_err();
422 assert!(err.contains("invalid commit hash"));
423 let err = restore(&shadow, &workdir, "ZZZZ").await.unwrap_err();
425 assert!(err.contains("invalid commit hash"));
426 let err = restore(&shadow, &workdir, "abc").await.unwrap_err();
428 assert!(err.contains("invalid commit hash"));
429
430 let _ = std::fs::remove_dir_all(&workdir);
431 let _ = std::fs::remove_dir_all(shadow.parent().unwrap());
432 }
433
434 #[tokio::test]
435 async fn empty_workspace_gets_a_restorable_baseline() {
436 if !git_available().await {
437 return;
438 }
439 let workdir = tmp_dir();
440 let shadow = tmp_dir().join("shadow");
441 let baseline = snapshot(&shadow, &workdir, "empty baseline")
442 .await
443 .unwrap()
444 .expect("first snapshot should create an empty commit");
445 std::fs::write(workdir.join("created.txt"), "created later").unwrap();
446
447 restore(&shadow, &workdir, &baseline).await.unwrap();
448 assert!(!workdir.join("created.txt").exists());
449
450 let _ = std::fs::remove_dir_all(&workdir);
451 let _ = std::fs::remove_dir_all(shadow.parent().unwrap());
452 }
453
454 #[test]
455 fn shadow_repo_path_is_deterministic() {
456 let root = PathBuf::from("/tmp/data/checkpoints");
457 let wd = std::env::temp_dir(); let p1 = shadow_repo_path(&root, &wd);
459 let p2 = shadow_repo_path(&root, &wd);
460 assert_eq!(p1, p2);
461 assert_eq!(p1.parent(), Some(root.as_path()));
462 assert_eq!(p1.file_name().unwrap().len(), 16);
463 }
464
465 #[tokio::test]
466 async fn list_returns_empty_for_uninitialised_repo() {
467 let shadow = tmp_dir().join("never_initialised");
468 let workdir = tmp_dir();
469 let entries = list(&shadow, &workdir, 5).await.unwrap();
470 assert!(entries.is_empty());
471 let _ = std::fs::remove_dir_all(&workdir);
472 }
473}