Skip to main content

microclaw_engine/
checkpoint.rs

1//! Filesystem checkpoints via shadow git repos.
2//!
3//! Once per agent turn we run `git add -A && git commit` against a shadow
4//! repo whose `GIT_DIR` lives outside the user's working tree (under the
5//! configured data dir). Tools never see this — it's transparent
6//! infrastructure. Users can `/rewind` to any prior checkpoint.
7//!
8//! Hermes reference: `tools/checkpoint_manager.py`. This is a focused port:
9//! we cover init, snapshot, list, restore. We do NOT expose per-file restore
10//! or per-checkpoint diff yet.
11//!
12//! Isolation: env vars redirect git to the shadow `GIT_DIR` and keep it from
13//! reading the user's `~/.gitconfig` (no signing prompts, no hooks, no
14//! credential helpers).
15
16use 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
55/// Path to the shadow repo for a given working dir, anchored under the data
56/// dir's `checkpoints` subfolder. Per-(checkpoints_root, working_dir) so two
57/// different chats with separate working dirs get separate shadow repos.
58pub 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
69/// Initialise the shadow repo if not already present. Idempotent.
70pub 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
124/// Stage everything and commit. Returns the new commit hash, or None if
125/// there were no changes to snapshot.
126pub 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    // `git diff --cached --quiet` returns 0 if no diff, 1 if diff exists.
135    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
171/// List the most recent checkpoints (newest first), capped at `limit`.
172pub 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
216/// Restore the working directory to the state at `commit_hash`. Validates
217/// the hash is a hex string to avoid argument injection.
218pub 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    // Reset the shadow index and work tree to the checkpoint without moving
240    // HEAD. Unlike `git checkout <hash> -- :/`, this also handles an empty
241    // baseline and removes paths tracked only by a later checkpoint.
242    run_git(
243        &["read-tree", "--reset", "-u", commit_hash],
244        shadow_repo,
245        working_dir,
246    )
247    .await?;
248    // Remove files created after the checkpoint. Standard excludes remain
249    // protected, and nested repositories require an additional force flag
250    // that we intentionally do not provide.
251    run_git(&["clean", "-fd"], shadow_repo, working_dir).await?;
252    Ok(())
253}
254
255// ---------------------------------------------------------------------------
256// Internals
257// ---------------------------------------------------------------------------
258
259fn 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        // Round 1
355        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        // Round 2
360        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        // No-op snapshot returns None.
367        let c3 = snapshot(&shadow, &workdir, "round 3 noop").await.unwrap();
368        assert!(c3.is_none(), "no changes -> no commit");
369
370        // List shows both.
371        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        // Restore round 1.
377        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 round 2.
395        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        // Cleanup.
406        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        // Argument-injection guard: hash starting with '-'.
421        let err = restore(&shadow, &workdir, "--patch").await.unwrap_err();
422        assert!(err.contains("invalid commit hash"));
423        // Non-hex.
424        let err = restore(&shadow, &workdir, "ZZZZ").await.unwrap_err();
425        assert!(err.contains("invalid commit hash"));
426        // Too short.
427        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(); // exists
458        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}