Skip to main content

recursive/
checkpoint.rs

1//! Shadow bare git repository for per-session, per-turn checkpoints.
2//!
3//! A single bare repo lives at `<workspace>/.recursive/shadow-git/`.
4//! All sessions in the same workspace share that repo's object store
5//! (so identical file contents dedup automatically), but each session
6//! advances its own ref chain at `refs/sessions/<sid>/HEAD`.
7//!
8//! Checkpoints are taken automatically by `AgentRuntime` at the
9//! beginning and end of every turn — never by the agent itself.
10//! Restoration is **selective**: callers must specify which file paths
11//! to revert, leaving sibling sessions' work untouched.
12//!
13//! Implementation note: this module shells out to `git` via
14//! `std::process::Command` so no new Cargo dependency is required.
15
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use crate::error::{Error, Result};
21
22// ── public types ─────────────────────────────────────────────────────────────
23
24/// 12-char short SHA identifying one checkpoint commit.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
26pub struct CheckpointId(pub String);
27
28impl std::fmt::Display for CheckpointId {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "{}", self.0)
31    }
32}
33
34/// Metadata for a single checkpoint commit.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct CheckpointInfo {
37    pub id: CheckpointId,
38    pub message: String,
39    /// Unix seconds.
40    pub timestamp: i64,
41    /// Number of files that changed relative to the previous checkpoint
42    /// in this session's chain.
43    pub files_changed: usize,
44}
45
46/// Result of a [`ShadowRepo::restore_paths`] call.
47#[derive(Debug, Clone, Default)]
48pub struct RestoreStats {
49    /// Files whose content was overwritten with checkpoint content.
50    pub restored: usize,
51    /// Files that were deleted because they did not exist at the
52    /// target checkpoint.
53    pub deleted: usize,
54    /// Files in `paths` that didn't need any change (already matched).
55    pub unchanged: usize,
56}
57
58// ── ShadowRepo ────────────────────────────────────────────────────────────────
59
60/// A shared shadow bare git repository for a workspace.
61///
62/// Per-session checkpoint chains live under `refs/sessions/<sid>/HEAD`.
63#[derive(Debug, Clone)]
64pub struct ShadowRepo {
65    workspace: PathBuf,
66    shadow_dir: PathBuf,
67}
68
69impl ShadowRepo {
70    /// Open or create the shadow repo for `workspace`. Idempotent.
71    /// Returns an error if `git` is not on PATH.
72    pub fn open(workspace: impl Into<PathBuf>) -> Result<Self> {
73        let workspace = workspace.into().canonicalize().map_err(|e| Error::Tool {
74            name: "checkpoint".into(),
75            message: format!("cannot canonicalize workspace: {e}"),
76        })?;
77        let shadow_dir = crate::paths::user_shadow_git_dir(&workspace)?;
78        Self::open_at(workspace, shadow_dir)
79    }
80
81    /// Open or create a shadow repo with an explicit `shadow_dir`,
82    /// bypassing `paths::user_data_dir()` resolution.
83    ///
84    /// Used by tests so they don't have to mutate `RECURSIVE_HOME`
85    /// (and thus don't need the cross-module env lock), letting
86    /// checkpoint tests run in parallel. Production callers should
87    /// stick to [`open`].
88    pub fn open_at(workspace: impl Into<PathBuf>, shadow_dir: impl Into<PathBuf>) -> Result<Self> {
89        let workspace = workspace.into();
90        let shadow_dir = shadow_dir.into();
91        if !shadow_dir.exists() {
92            std::fs::create_dir_all(&shadow_dir).map_err(|e| Error::Tool {
93                name: "checkpoint".into(),
94                message: format!("cannot create shadow-git dir: {e}"),
95            })?;
96            let out = git_cmd()
97                .args(["init", "--bare"])
98                .current_dir(&shadow_dir)
99                .output()
100                .map_err(|e| Error::Tool {
101                    name: "checkpoint".into(),
102                    message: format!("git not found or failed: {e}"),
103                })?;
104            if !out.status.success() {
105                return Err(Error::Tool {
106                    name: "checkpoint".into(),
107                    message: format!(
108                        "git init --bare failed: {}",
109                        String::from_utf8_lossy(&out.stderr)
110                    ),
111                });
112            }
113        }
114
115        Ok(Self {
116            workspace,
117            shadow_dir,
118        })
119    }
120
121    /// The workspace this repo snapshots.
122    pub fn workspace(&self) -> &Path {
123        &self.workspace
124    }
125
126    /// Snapshot the current workspace state and advance
127    /// `refs/sessions/<session_id>/HEAD` to the new commit.
128    ///
129    /// Per-session temporary index files prevent concurrent snapshots
130    /// from racing each other.
131    pub fn snapshot_for_session(&self, session_id: &str, message: &str) -> Result<CheckpointId> {
132        validate_session_id(session_id)?;
133
134        let tmp_index = self.shadow_dir.join(format!("tmp-index-{session_id}"));
135        // Ensure no stale index from a crashed prior run.
136        let _ = std::fs::remove_file(&tmp_index);
137
138        // We exclude the `.recursive/` directory entirely so the shadow
139        // repo never snapshots its own internals or sibling sessions'
140        // state files. The pathspec `:!.recursive` is git's
141        // exclude-pathspec syntax; it applies relative to the
142        // work-tree root.
143        let add_out = git_cmd()
144            .env("GIT_INDEX_FILE", &tmp_index)
145            .env("GIT_DIR", &self.shadow_dir)
146            .env("GIT_WORK_TREE", &self.workspace)
147            .args([
148                "add",
149                "-A",
150                "--force",
151                "--",
152                ".",
153                ":(exclude,glob).recursive/**",
154                ":(exclude,glob).recursive",
155            ])
156            .output()
157            .map_err(git_err)?;
158
159        if !add_out.status.success() {
160            let stderr = String::from_utf8_lossy(&add_out.stderr);
161            if !stderr.trim().is_empty()
162                && !stderr.contains("nothing to commit")
163                && !stderr.contains("warning:")
164            {
165                let _ = std::fs::remove_file(&tmp_index);
166                return Err(Error::Tool {
167                    name: "checkpoint".into(),
168                    message: format!("git add failed: {stderr}"),
169                });
170            }
171        }
172
173        let tree_out = git_cmd()
174            .env("GIT_INDEX_FILE", &tmp_index)
175            .env("GIT_DIR", &self.shadow_dir)
176            .args(["write-tree"])
177            .output()
178            .map_err(git_err)?;
179        let _ = std::fs::remove_file(&tmp_index);
180
181        if !tree_out.status.success() {
182            return Err(Error::Tool {
183                name: "checkpoint".into(),
184                message: format!(
185                    "git write-tree failed: {}",
186                    String::from_utf8_lossy(&tree_out.stderr)
187                ),
188            });
189        }
190        let tree_sha = String::from_utf8_lossy(&tree_out.stdout).trim().to_string();
191
192        // Read this session's current HEAD (if any) for parent linkage.
193        let parent = self.session_head_full_sha(session_id);
194
195        let mut ct_args = vec!["commit-tree".to_string(), tree_sha.clone()];
196        if let Some(ref p) = parent {
197            ct_args.push("-p".to_string());
198            ct_args.push(p.clone());
199        }
200        ct_args.push("-m".to_string());
201        ct_args.push(message.to_string());
202
203        let commit_out = git_cmd()
204            .env("GIT_DIR", &self.shadow_dir)
205            .env("GIT_AUTHOR_NAME", "recursive-agent")
206            .env("GIT_AUTHOR_EMAIL", "agent@recursive")
207            .env("GIT_COMMITTER_NAME", "recursive-agent")
208            .env("GIT_COMMITTER_EMAIL", "agent@recursive")
209            .args(&ct_args)
210            .output()
211            .map_err(git_err)?;
212
213        if !commit_out.status.success() {
214            return Err(Error::Tool {
215                name: "checkpoint".into(),
216                message: format!(
217                    "git commit-tree failed: {}",
218                    String::from_utf8_lossy(&commit_out.stderr)
219                ),
220            });
221        }
222        let commit_sha = String::from_utf8_lossy(&commit_out.stdout)
223            .trim()
224            .to_string();
225
226        // Atomic ref update via `git update-ref`. Provides locking
227        // and prevents two concurrent snapshots from clobbering each
228        // other's HEAD.
229        let ref_name = session_ref(session_id);
230        let mut update_args = vec!["update-ref".to_string(), ref_name, commit_sha.clone()];
231        if let Some(p) = parent {
232            update_args.push(p);
233        }
234        let upd_out = git_cmd()
235            .env("GIT_DIR", &self.shadow_dir)
236            .args(&update_args)
237            .output()
238            .map_err(git_err)?;
239        if !upd_out.status.success() {
240            return Err(Error::Tool {
241                name: "checkpoint".into(),
242                message: format!(
243                    "git update-ref failed: {}",
244                    String::from_utf8_lossy(&upd_out.stderr)
245                ),
246            });
247        }
248
249        Ok(CheckpointId(short_sha(&commit_sha)))
250    }
251
252    /// List checkpoints for `session_id` in reverse chronological order.
253    pub fn list_for_session(&self, session_id: &str) -> Result<Vec<CheckpointInfo>> {
254        validate_session_id(session_id)?;
255        let head = match self.session_head_full_sha(session_id) {
256            None => return Ok(vec![]),
257            Some(h) => h,
258        };
259
260        let log_out = git_cmd()
261            .env("GIT_DIR", &self.shadow_dir)
262            .args(["log", "--format=%H|%ct|%s", &head])
263            .output()
264            .map_err(git_err)?;
265
266        if !log_out.status.success() {
267            return Ok(vec![]);
268        }
269        let stdout = String::from_utf8_lossy(&log_out.stdout);
270        let lines: Vec<&str> = stdout.lines().collect();
271
272        let mut infos = Vec::with_capacity(lines.len());
273        for (i, line) in lines.iter().enumerate() {
274            let parts: Vec<&str> = line.splitn(3, '|').collect();
275            if parts.len() < 3 {
276                continue;
277            }
278            let full_sha = parts[0].to_string();
279            let timestamp: i64 = parts[1].parse().unwrap_or(0);
280            let msg = parts[2].to_string();
281
282            let files_changed = if i + 1 < lines.len() {
283                self.count_diff_files(&format!("{full_sha}^"), &full_sha)
284            } else {
285                // Root commit — diff against empty tree.
286                self.count_diff_files(EMPTY_TREE_SHA, &full_sha)
287            };
288
289            infos.push(CheckpointInfo {
290                id: CheckpointId(short_sha(&full_sha)),
291                message: msg,
292                timestamp,
293                files_changed,
294            });
295        }
296        Ok(infos)
297    }
298
299    /// Read a single file's contents at `checkpoint`.
300    /// Returns `Ok(None)` if the file did not exist at that checkpoint.
301    pub fn read_file_at(&self, checkpoint: &CheckpointId, path: &str) -> Result<Option<Vec<u8>>> {
302        let full = self.expand_sha(&checkpoint.0)?;
303        let spec = format!("{full}:{path}");
304        let out = git_cmd()
305            .env("GIT_DIR", &self.shadow_dir)
306            .args(["cat-file", "-p", &spec])
307            .output()
308            .map_err(git_err)?;
309        if out.status.success() {
310            Ok(Some(out.stdout))
311        } else {
312            // git cat-file fails with non-zero when path is absent.
313            // Distinguish "missing path" from real errors by stderr text.
314            let stderr = String::from_utf8_lossy(&out.stderr);
315            if stderr.contains("does not exist")
316                || stderr.contains("not a valid object name")
317                || stderr.contains("Not a valid object name")
318                || stderr.contains("exists on disk, but not in")
319            {
320                Ok(None)
321            } else {
322                Err(Error::Tool {
323                    name: "checkpoint".into(),
324                    message: format!("git cat-file failed: {stderr}"),
325                })
326            }
327        }
328    }
329
330    /// Restore *only* the given workspace-relative file `paths` to
331    /// their state at `checkpoint`. Files not in `paths` remain
332    /// untouched. Files present in the workspace but not in the
333    /// checkpoint tree are deleted (when listed in `paths`).
334    pub fn restore_paths(
335        &self,
336        checkpoint: &CheckpointId,
337        paths: &[String],
338    ) -> Result<RestoreStats> {
339        let full = self.expand_sha(&checkpoint.0)?;
340        let mut stats = RestoreStats::default();
341
342        for path in paths {
343            let abs = self.workspace.join(path);
344            let cp_bytes = self.read_file_at(checkpoint, path)?;
345            let current_bytes = std::fs::read(&abs).ok();
346
347            match (cp_bytes, current_bytes) {
348                (None, None) => {
349                    stats.unchanged += 1;
350                }
351                (None, Some(_)) => {
352                    // Existed in workspace but not in checkpoint → delete.
353                    if abs.exists() {
354                        std::fs::remove_file(&abs).map_err(|e| Error::Tool {
355                            name: "checkpoint".into(),
356                            message: format!("cannot delete {path}: {e}"),
357                        })?;
358                        stats.deleted += 1;
359                    }
360                }
361                (Some(want), Some(have)) if want == have => {
362                    stats.unchanged += 1;
363                }
364                (Some(want), _) => {
365                    if let Some(parent) = abs.parent() {
366                        std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
367                            name: "checkpoint".into(),
368                            message: format!("cannot create dir for {path}: {e}"),
369                        })?;
370                    }
371                    std::fs::write(&abs, &want).map_err(|e| Error::Tool {
372                        name: "checkpoint".into(),
373                        message: format!("cannot restore {path}: {e}"),
374                    })?;
375                    stats.restored += 1;
376                }
377            }
378        }
379
380        // Suppress unused-variable lint by referring to full once more.
381        let _ = full;
382        Ok(stats)
383    }
384
385    /// Diff between two checkpoints (or `a` vs current workspace if
386    /// `b` is None), optionally limited to `paths`.
387    pub fn diff(
388        &self,
389        a: &CheckpointId,
390        b: Option<&CheckpointId>,
391        paths: &[String],
392    ) -> Result<String> {
393        let a_full = self.expand_sha(&a.0)?;
394        let b_full = match b {
395            Some(id) => self.expand_sha(&id.0)?,
396            None => self.write_workspace_tree()?,
397        };
398
399        let mut args: Vec<String> = vec!["diff".to_string(), a_full, b_full];
400        if !paths.is_empty() {
401            args.push("--".to_string());
402            for p in paths {
403                args.push(p.clone());
404            }
405        }
406        let out = git_cmd()
407            .env("GIT_DIR", &self.shadow_dir)
408            .args(&args)
409            .output()
410            .map_err(git_err)?;
411        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
412    }
413
414    /// List file paths changed between checkpoints `a` and `b`.
415    /// Used for "shell-diff" attribution after `run_shell` calls.
416    pub fn changed_paths(&self, a: &CheckpointId, b: &CheckpointId) -> Result<Vec<String>> {
417        let a_full = self.expand_sha(&a.0)?;
418        let b_full = self.expand_sha(&b.0)?;
419        let out = git_cmd()
420            .env("GIT_DIR", &self.shadow_dir)
421            .args(["diff-tree", "--name-only", "-r", &a_full, &b_full])
422            .output()
423            .map_err(git_err)?;
424        Ok(String::from_utf8_lossy(&out.stdout)
425            .lines()
426            .map(|s| s.trim().to_string())
427            .filter(|s| !s.is_empty())
428            .collect())
429    }
430
431    /// Drop the shadow repo entirely. Used by tests and `clean` UX.
432    pub fn clean(&self) -> Result<()> {
433        if self.shadow_dir.exists() {
434            std::fs::remove_dir_all(&self.shadow_dir).map_err(|e| Error::Tool {
435                name: "checkpoint".into(),
436                message: format!("cannot remove shadow-git: {e}"),
437            })?;
438        }
439        Ok(())
440    }
441
442    // ── private helpers ───────────────────────────────────────────────────────
443
444    fn session_head_full_sha(&self, session_id: &str) -> Option<String> {
445        let out = git_cmd()
446            .env("GIT_DIR", &self.shadow_dir)
447            .args(["rev-parse", "--verify", "--quiet", &session_ref(session_id)])
448            .output()
449            .ok()?;
450        if !out.status.success() {
451            return None;
452        }
453        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
454        if s.is_empty() {
455            None
456        } else {
457            Some(s)
458        }
459    }
460
461    /// Stage the current workspace into a temp index and return the
462    /// resulting tree SHA (used by `diff` against current workspace).
463    fn write_workspace_tree(&self) -> Result<String> {
464        let tmp_index = self.shadow_dir.join("tmp-index-diff");
465        let _ = std::fs::remove_file(&tmp_index);
466
467        let _ = git_cmd()
468            .env("GIT_INDEX_FILE", &tmp_index)
469            .env("GIT_DIR", &self.shadow_dir)
470            .env("GIT_WORK_TREE", &self.workspace)
471            .args([
472                "add",
473                "-A",
474                "--force",
475                "--",
476                ".",
477                ":(exclude,glob).recursive/**",
478                ":(exclude,glob).recursive",
479            ])
480            .output();
481
482        let tree_out = git_cmd()
483            .env("GIT_INDEX_FILE", &tmp_index)
484            .env("GIT_DIR", &self.shadow_dir)
485            .args(["write-tree"])
486            .output()
487            .map_err(git_err)?;
488        let _ = std::fs::remove_file(&tmp_index);
489        if !tree_out.status.success() {
490            return Err(Error::Tool {
491                name: "checkpoint".into(),
492                message: format!(
493                    "git write-tree failed: {}",
494                    String::from_utf8_lossy(&tree_out.stderr)
495                ),
496            });
497        }
498        Ok(String::from_utf8_lossy(&tree_out.stdout).trim().to_string())
499    }
500
501    fn expand_sha(&self, short: &str) -> Result<String> {
502        // Try direct rev-parse of "<short>^{commit}" — fastest path.
503        let out = git_cmd()
504            .env("GIT_DIR", &self.shadow_dir)
505            .args(["rev-parse", &format!("{short}^{{commit}}")])
506            .output()
507            .map_err(git_err)?;
508        if out.status.success() {
509            return Ok(String::from_utf8_lossy(&out.stdout).trim().to_string());
510        }
511        // Try plain rev-parse for tree-like refs.
512        let out2 = git_cmd()
513            .env("GIT_DIR", &self.shadow_dir)
514            .args(["rev-parse", short])
515            .output()
516            .map_err(git_err)?;
517        if out2.status.success() {
518            return Ok(String::from_utf8_lossy(&out2.stdout).trim().to_string());
519        }
520        Err(Error::Tool {
521            name: "checkpoint".into(),
522            message: format!("checkpoint '{short}' not found"),
523        })
524    }
525
526    fn count_diff_files(&self, a: &str, b: &str) -> usize {
527        let out = git_cmd()
528            .env("GIT_DIR", &self.shadow_dir)
529            .args(["diff-tree", "--name-only", "-r", a, b])
530            .output();
531        match out {
532            Ok(o) => String::from_utf8_lossy(&o.stdout)
533                .lines()
534                .filter(|l| !l.trim().is_empty())
535                .count(),
536            Err(_) => 0,
537        }
538    }
539}
540
541// ── helpers ───────────────────────────────────────────────────────────────────
542
543const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
544
545fn git_cmd() -> Command {
546    let mut cmd = Command::new("git");
547    // Bypass safe.directory ownership checks so git works inside containers
548    // and CI environments where the repo owner differs from the running user.
549    cmd.arg("-c").arg("safe.directory=*");
550    cmd.env("GIT_CONFIG_NOSYSTEM", "1");
551    cmd.env("HOME", std::env::temp_dir());
552    cmd.env("GIT_PAGER", "cat");
553    cmd.env("GIT_TERMINAL_PROMPT", "0");
554    // Force English locale so stderr-message matching (e.g. for the
555    // "path not in tree" case in `read_file_at`) is stable across
556    // user environments that have a localized git installation.
557    cmd.env("LC_ALL", "C");
558    cmd.env("LANG", "C");
559    // Clear any ambient GIT_DIR / GIT_WORK_TREE that a parent process (e.g. a
560    // pre-receive hook) might have set. Commands that need these variables set
561    // them explicitly; leaving them inherited causes `git init --bare` to fail
562    // with "GIT_WORK_TREE not allowed without GIT_DIR".
563    cmd.env_remove("GIT_DIR");
564    cmd.env_remove("GIT_WORK_TREE");
565    cmd
566}
567
568fn git_err(e: std::io::Error) -> Error {
569    Error::Tool {
570        name: "checkpoint".into(),
571        message: format!("git invocation failed: {e}"),
572    }
573}
574
575fn short_sha(full: &str) -> String {
576    full.chars().take(12).collect()
577}
578
579fn session_ref(sid: &str) -> String {
580    format!("refs/sessions/{}/HEAD", sanitize_for_refname(sid))
581}
582
583/// Encode a session id into a git refname-safe segment. Git refnames
584/// disallow consecutive dots (`..`), leading/trailing dots, `.lock`
585/// suffixes, and a few control characters. For our purposes (we
586/// already pre-validate via [`validate_session_id`]) we just collapse
587/// any `.` into `-`, which is always safe and deterministic.
588fn sanitize_for_refname(sid: &str) -> String {
589    sid.replace('.', "-")
590}
591
592fn validate_session_id(sid: &str) -> Result<()> {
593    // Allow alphanumerics + `-` `_` `.`. The `.` is permitted because
594    // real session ids include the workspace slug, which on macOS may
595    // contain `.tmpXXX` segments from `/var/folders/...`. We still
596    // reject path separators, `..`, and leading-dot to keep the id
597    // safe for use as a git ref component.
598    if sid.is_empty()
599        || sid.contains('/')
600        || sid.contains('\\')
601        || sid.contains("..")
602        || sid.starts_with('.')
603        || !sid
604            .chars()
605            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
606    {
607        return Err(Error::BadToolArgs {
608            name: "checkpoint".into(),
609            message: format!("invalid session_id `{sid}` (must be alphanumeric/-/_/.)"),
610        });
611    }
612    Ok(())
613}
614
615// ── tests ─────────────────────────────────────────────────────────────────────
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use std::fs;
621    use tempfile::TempDir;
622
623    fn has_git() -> bool {
624        Command::new("git").arg("--version").output().is_ok()
625    }
626
627    /// Test workspace bundle: a workspace tempdir + a sibling tempdir
628    /// to use as the shadow-git directory. Tests pass `ws.shadow_dir()`
629    /// to `ShadowRepo::open_at` so they bypass `paths::user_data_dir()`
630    /// entirely — no env mutation, no global lock, full parallelism.
631    struct TestWs {
632        workspace: TempDir,
633        shadow: TempDir,
634    }
635
636    impl TestWs {
637        fn path(&self) -> &std::path::Path {
638            self.workspace.path()
639        }
640        fn shadow_dir(&self) -> std::path::PathBuf {
641            self.shadow.path().join("shadow-git")
642        }
643        /// Convenience for tests that previously called
644        /// `ShadowRepo::open(w.path())`.
645        fn open_repo(&self) -> Result<ShadowRepo> {
646            ShadowRepo::open_at(self.path(), self.shadow_dir())
647        }
648    }
649
650    fn ws() -> TestWs {
651        TestWs {
652            workspace: tempfile::tempdir().expect("workspace tempdir"),
653            shadow: tempfile::tempdir().expect("shadow tempdir"),
654        }
655    }
656
657    #[test]
658    fn shadow_repo_init_creates_dir() {
659        if !has_git() {
660            return;
661        }
662        let w = ws();
663        let r = w.open_repo().expect("open");
664        assert!(r.shadow_dir.exists());
665        assert!(r.shadow_dir.join("HEAD").exists());
666    }
667
668    #[test]
669    fn validate_session_id_rejects_paths() {
670        assert!(validate_session_id("").is_err());
671        assert!(validate_session_id("a/b").is_err());
672        assert!(validate_session_id("..").is_err());
673        assert!(validate_session_id(".hidden").is_err());
674        assert!(validate_session_id("ok-1").is_ok());
675        assert!(validate_session_id("ok_2").is_ok());
676        assert!(validate_session_id("AbCdef123").is_ok());
677        // Real-world session ids contain `.` from macOS tmpdirs.
678        assert!(validate_session_id("2026-05-29T00-09-56Z-var-folders-T-.tmpAbc").is_ok());
679    }
680
681    #[test]
682    fn sanitize_for_refname_collapses_dots() {
683        assert_eq!(sanitize_for_refname("a.b.c"), "a-b-c");
684        assert_eq!(sanitize_for_refname("plain"), "plain");
685    }
686
687    #[test]
688    fn snapshot_per_session_independent() {
689        if !has_git() {
690            return;
691        }
692        let w = ws();
693        fs::write(w.path().join("a.txt"), "from-A").unwrap();
694        let r = w.open_repo().unwrap();
695        let id_a1 = r.snapshot_for_session("sessA", "A turn 0").unwrap();
696
697        fs::write(w.path().join("a.txt"), "from-B").unwrap();
698        let id_b1 = r.snapshot_for_session("sessB", "B turn 0").unwrap();
699
700        let list_a = r.list_for_session("sessA").unwrap();
701        let list_b = r.list_for_session("sessB").unwrap();
702        assert_eq!(list_a.len(), 1, "A should see only its own checkpoint");
703        assert_eq!(list_b.len(), 1, "B should see only its own checkpoint");
704        assert_eq!(list_a[0].id, id_a1);
705        assert_eq!(list_b[0].id, id_b1);
706    }
707
708    #[test]
709    fn snapshot_dedups_objects() {
710        if !has_git() {
711            return;
712        }
713        let w = ws();
714        fs::write(w.path().join("same.txt"), "identical content").unwrap();
715        let r = w.open_repo().unwrap();
716        let _ = r.snapshot_for_session("a", "A").unwrap();
717        let _ = r.snapshot_for_session("b", "B").unwrap();
718
719        // The blob "identical content" appears once. We can verify by
720        // listing all blobs via `git cat-file --batch-check
721        // --batch-all-objects` and counting matching content size.
722        let out = git_cmd()
723            .env("GIT_DIR", &r.shadow_dir)
724            .args(["cat-file", "--batch-check", "--batch-all-objects"])
725            .output()
726            .unwrap();
727        let stdout = String::from_utf8_lossy(&out.stdout);
728        let blob_count = stdout.lines().filter(|l| l.contains(" blob ")).count();
729        // 1 file → 1 blob, regardless of how many sessions snapshotted.
730        assert_eq!(blob_count, 1, "blobs should dedupe across sessions");
731    }
732
733    #[test]
734    fn restore_paths_only_touches_specified_files() {
735        if !has_git() {
736            return;
737        }
738        let w = ws();
739        let x = w.path().join("X.txt");
740        let y = w.path().join("Y.txt");
741        fs::write(&x, "x-orig").unwrap();
742        fs::write(&y, "y-orig").unwrap();
743
744        let r = w.open_repo().unwrap();
745        let cp = r.snapshot_for_session("s", "init").unwrap();
746
747        fs::write(&x, "x-modified").unwrap();
748        fs::write(&y, "y-modified").unwrap();
749
750        let stats = r.restore_paths(&cp, &["X.txt".into()]).expect("restore");
751        assert_eq!(stats.restored, 1);
752
753        assert_eq!(fs::read_to_string(&x).unwrap(), "x-orig");
754        assert_eq!(
755            fs::read_to_string(&y).unwrap(),
756            "y-modified",
757            "Y must not be restored"
758        );
759    }
760
761    #[test]
762    fn restore_paths_handles_deletion() {
763        if !has_git() {
764            return;
765        }
766        let w = ws();
767        fs::write(w.path().join("keeper.txt"), "k").unwrap();
768        let r = w.open_repo().unwrap();
769        let cp = r.snapshot_for_session("s", "before-new").unwrap();
770
771        let nf = w.path().join("new.txt");
772        fs::write(&nf, "added later").unwrap();
773
774        let stats = r.restore_paths(&cp, &["new.txt".into()]).expect("restore");
775        assert_eq!(stats.deleted, 1);
776        assert!(!nf.exists());
777    }
778
779    #[test]
780    fn read_file_at_returns_none_for_missing() {
781        if !has_git() {
782            return;
783        }
784        let w = ws();
785        fs::write(w.path().join("a.txt"), "exists").unwrap();
786        let r = w.open_repo().unwrap();
787        let cp = r.snapshot_for_session("s", "init").unwrap();
788        assert_eq!(
789            r.read_file_at(&cp, "a.txt").unwrap(),
790            Some(b"exists".to_vec())
791        );
792        assert_eq!(r.read_file_at(&cp, "ghost.txt").unwrap(), None);
793    }
794
795    #[test]
796    fn changed_paths_lists_files_between_checkpoints() {
797        if !has_git() {
798            return;
799        }
800        let w = ws();
801        fs::write(w.path().join("a.txt"), "1").unwrap();
802        let r = w.open_repo().unwrap();
803        let c1 = r.snapshot_for_session("s", "v1").unwrap();
804        fs::write(w.path().join("a.txt"), "2").unwrap();
805        fs::write(w.path().join("b.txt"), "new").unwrap();
806        let c2 = r.snapshot_for_session("s", "v2").unwrap();
807
808        let changed = r.changed_paths(&c1, &c2).unwrap();
809        let set: std::collections::HashSet<_> = changed.into_iter().collect();
810        assert!(set.contains("a.txt"));
811        assert!(set.contains("b.txt"));
812    }
813
814    #[test]
815    fn list_for_session_returns_empty_before_any_snapshot() {
816        if !has_git() {
817            return;
818        }
819        let w = ws();
820        let r = w.open_repo().unwrap();
821        assert!(r.list_for_session("never").unwrap().is_empty());
822    }
823
824    #[test]
825    fn worktree_workspace_supported() {
826        if !has_git() {
827            return;
828        }
829        let w = ws();
830        // Simulate a `git worktree`: workspace's .git is a file.
831        fs::write(
832            w.path().join(".git"),
833            "gitdir: /elsewhere/.git/worktrees/foo\n",
834        )
835        .unwrap();
836        let r = w.open_repo().expect("open with worktree");
837        // Snapshots still work.
838        fs::write(w.path().join("a.txt"), "hi").unwrap();
839        let _ = r.snapshot_for_session("s", "wt").unwrap();
840    }
841
842    #[test]
843    fn concurrent_snapshots_use_distinct_temp_indexes() {
844        if !has_git() {
845            return;
846        }
847        // Sequential test of the temp-index naming invariant. True
848        // concurrency would require threads; the goal here is to
849        // verify that `tmp-index-<sid>` is per-session so no overlap
850        // can happen under load.
851        let w = ws();
852        fs::write(w.path().join("a.txt"), "v1").unwrap();
853        let r = w.open_repo().unwrap();
854        let _ = r.snapshot_for_session("alpha", "1").unwrap();
855        // Tmp index should be cleaned up after each call.
856        assert!(!r.shadow_dir.join("tmp-index-alpha").exists());
857        let _ = r.snapshot_for_session("beta", "1").unwrap();
858        assert!(!r.shadow_dir.join("tmp-index-beta").exists());
859    }
860}