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            call_id: None,
76            message: format!("cannot canonicalize workspace: {e}"),
77        })?;
78        // On Windows, std::fs::canonicalize() returns extended-length UNC paths
79        // (\\?\C:\...) which git does not accept as GIT_WORK_TREE values.
80        // Strip the prefix so git receives a plain drive-letter path.
81        #[cfg(windows)]
82        let workspace = {
83            let s = workspace.to_string_lossy();
84            if let Some(stripped) = s.strip_prefix(r"\\?\") {
85                PathBuf::from(stripped)
86            } else {
87                workspace
88            }
89        };
90        let shadow_dir = crate::paths::user_shadow_git_dir(&workspace)?;
91        Self::open_at(workspace, shadow_dir)
92    }
93
94    /// Open or create a shadow repo with an explicit `shadow_dir`,
95    /// bypassing `paths::user_data_dir()` resolution.
96    ///
97    /// Used by tests so they don't have to mutate `RECURSIVE_HOME`
98    /// (and thus don't need the cross-module env lock), letting
99    /// checkpoint tests run in parallel. Production callers should
100    /// stick to [`open`].
101    pub fn open_at(workspace: impl Into<PathBuf>, shadow_dir: impl Into<PathBuf>) -> Result<Self> {
102        let workspace = workspace.into();
103        let shadow_dir = shadow_dir.into();
104        if !shadow_dir.exists() {
105            std::fs::create_dir_all(&shadow_dir).map_err(|e| Error::Tool {
106                name: "checkpoint".into(),
107                call_id: None,
108                message: format!("cannot create shadow-git dir: {e}"),
109            })?;
110            let out = git_cmd()
111                .args(["init", "--bare"])
112                .current_dir(&shadow_dir)
113                .output()
114                .map_err(|e| Error::Tool {
115                    name: "checkpoint".into(),
116                    call_id: None,
117                    message: format!("git not found or failed: {e}"),
118                })?;
119            if !out.status.success() {
120                return Err(Error::Tool {
121                    name: "checkpoint".into(),
122                    call_id: None,
123                    message: format!(
124                        "git init --bare failed: {}",
125                        String::from_utf8_lossy(&out.stderr)
126                    ),
127                });
128            }
129        }
130
131        Ok(Self {
132            workspace,
133            shadow_dir,
134        })
135    }
136
137    /// The workspace this repo snapshots.
138    pub fn workspace(&self) -> &Path {
139        &self.workspace
140    }
141
142    /// Snapshot the current workspace state and advance
143    /// `refs/sessions/<session_id>/HEAD` to the new commit.
144    ///
145    /// Per-session temporary index files prevent concurrent snapshots
146    /// from racing each other.
147    pub fn snapshot_for_session(&self, session_id: &str, message: &str) -> Result<CheckpointId> {
148        validate_session_id(session_id)?;
149
150        let tmp_index = self.shadow_dir.join(format!("tmp-index-{session_id}"));
151        // Ensure no stale index from a crashed prior run.
152        let _ = std::fs::remove_file(&tmp_index);
153
154        // We exclude the `.recursive/` directory entirely so the shadow
155        // repo never snapshots its own internals or sibling sessions'
156        // state files. Use `current_dir` so that pathspecs are resolved
157        // relative to the workspace even on Windows where git may not
158        // honour GIT_WORK_TREE for pathspec resolution.  The short-form
159        // `:!` exclude is more portable across git versions and platforms
160        // than the long-form `:(exclude,glob)` magic.
161        let add_out = git_cmd()
162            .env("GIT_INDEX_FILE", &tmp_index)
163            .env("GIT_DIR", &self.shadow_dir)
164            .env("GIT_WORK_TREE", &self.workspace)
165            .current_dir(&self.workspace)
166            .args([
167                "add",
168                "-A",
169                "--force",
170                "--",
171                ".",
172                // Never snapshot recursive's own internals or session state.
173                ":!.recursive/**",
174                ":!.recursive",
175                // Exclude large build / cache directories that are git-ignored
176                // in the project but would be captured by --force. Including
177                // them inflates each snapshot by gigabytes and causes the
178                // blocking git-add to take minutes rather than milliseconds.
179                ":!target/**",
180                ":!target",
181                // Common large auto-generated dirs across projects.
182                // Use `:**/` prefix so nested copies (e.g. website/node_modules/)
183                // are excluded in addition to the workspace root copy.
184                ":!**/node_modules/**",
185                ":!**/node_modules",
186                ":!.flowcast/runs/**",
187                ":!.flowcast/logs/**",
188                ":!.dev/runs/**",
189                ":!.dev/transcripts/**",
190                ":!.worktrees/**",
191            ])
192            .output()
193            .map_err(git_err)?;
194
195        if !add_out.status.success() {
196            let stderr = String::from_utf8_lossy(&add_out.stderr);
197            if !stderr.trim().is_empty()
198                && !stderr.contains("nothing to commit")
199                && !stderr.contains("warning:")
200            {
201                let _ = std::fs::remove_file(&tmp_index);
202                return Err(Error::Tool {
203                    name: "checkpoint".into(),
204                    call_id: None,
205                    message: format!("git add failed: {stderr}"),
206                });
207            }
208        }
209
210        let tree_out = git_cmd()
211            .env("GIT_INDEX_FILE", &tmp_index)
212            .env("GIT_DIR", &self.shadow_dir)
213            .args(["write-tree"])
214            .output()
215            .map_err(git_err)?;
216        let _ = std::fs::remove_file(&tmp_index);
217
218        if !tree_out.status.success() {
219            return Err(Error::Tool {
220                name: "checkpoint".into(),
221                call_id: None,
222                message: format!(
223                    "git write-tree failed: {}",
224                    String::from_utf8_lossy(&tree_out.stderr)
225                ),
226            });
227        }
228        let tree_sha = String::from_utf8_lossy(&tree_out.stdout).trim().to_string();
229
230        // Read this session's current HEAD (if any) for parent linkage.
231        let parent = self.session_head_full_sha(session_id);
232
233        let mut ct_args = vec!["commit-tree".to_string(), tree_sha.clone()];
234        if let Some(ref p) = parent {
235            ct_args.push("-p".to_string());
236            ct_args.push(p.clone());
237        }
238        ct_args.push("-m".to_string());
239        ct_args.push(message.to_string());
240
241        let commit_out = git_cmd()
242            .env("GIT_DIR", &self.shadow_dir)
243            .env("GIT_AUTHOR_NAME", "recursive-agent")
244            .env("GIT_AUTHOR_EMAIL", "agent@recursive")
245            .env("GIT_COMMITTER_NAME", "recursive-agent")
246            .env("GIT_COMMITTER_EMAIL", "agent@recursive")
247            .args(&ct_args)
248            .output()
249            .map_err(git_err)?;
250
251        if !commit_out.status.success() {
252            return Err(Error::Tool {
253                name: "checkpoint".into(),
254                call_id: None,
255                message: format!(
256                    "git commit-tree failed: {}",
257                    String::from_utf8_lossy(&commit_out.stderr)
258                ),
259            });
260        }
261        let commit_sha = String::from_utf8_lossy(&commit_out.stdout)
262            .trim()
263            .to_string();
264
265        // Atomic ref update via `git update-ref`. Provides locking
266        // and prevents two concurrent snapshots from clobbering each
267        // other's HEAD.
268        let ref_name = session_ref(session_id);
269        let mut update_args = vec!["update-ref".to_string(), ref_name, commit_sha.clone()];
270        if let Some(p) = parent {
271            update_args.push(p);
272        }
273        let upd_out = git_cmd()
274            .env("GIT_DIR", &self.shadow_dir)
275            .args(&update_args)
276            .output()
277            .map_err(git_err)?;
278        if !upd_out.status.success() {
279            return Err(Error::Tool {
280                name: "checkpoint".into(),
281                call_id: None,
282                message: format!(
283                    "git update-ref failed: {}",
284                    String::from_utf8_lossy(&upd_out.stderr)
285                ),
286            });
287        }
288
289        Ok(CheckpointId(short_sha(&commit_sha)))
290    }
291
292    /// List checkpoints for `session_id` in reverse chronological order.
293    pub fn list_for_session(&self, session_id: &str) -> Result<Vec<CheckpointInfo>> {
294        validate_session_id(session_id)?;
295        let head = match self.session_head_full_sha(session_id) {
296            None => return Ok(vec![]),
297            Some(h) => h,
298        };
299
300        let log_out = git_cmd()
301            .env("GIT_DIR", &self.shadow_dir)
302            .args(["log", "--format=%H|%ct|%s", &head])
303            .output()
304            .map_err(git_err)?;
305
306        if !log_out.status.success() {
307            return Ok(vec![]);
308        }
309        let stdout = String::from_utf8_lossy(&log_out.stdout);
310        let lines: Vec<&str> = stdout.lines().collect();
311
312        let mut infos = Vec::with_capacity(lines.len());
313        for (i, line) in lines.iter().enumerate() {
314            let parts: Vec<&str> = line.splitn(3, '|').collect();
315            if log_line_incomplete(parts.len()) {
316                continue;
317            }
318            let full_sha = parts[0].to_string();
319            let timestamp: i64 = parts[1].parse().unwrap_or(0);
320            let msg = parts[2].to_string();
321
322            let files_changed = if i + 1 < lines.len() {
323                self.count_diff_files(&format!("{full_sha}^"), &full_sha)
324            } else {
325                // Root commit — diff against empty tree.
326                self.count_diff_files(EMPTY_TREE_SHA, &full_sha)
327            };
328
329            infos.push(CheckpointInfo {
330                id: CheckpointId(short_sha(&full_sha)),
331                message: msg,
332                timestamp,
333                files_changed,
334            });
335        }
336        Ok(infos)
337    }
338
339    /// Read a single file's contents at `checkpoint`.
340    /// Returns `Ok(None)` if the file did not exist at that checkpoint.
341    pub fn read_file_at(&self, checkpoint: &CheckpointId, path: &str) -> Result<Option<Vec<u8>>> {
342        let full = self.expand_sha(&checkpoint.0)?;
343        let spec = format!("{full}:{path}");
344        let out = git_cmd()
345            .env("GIT_DIR", &self.shadow_dir)
346            .args(["cat-file", "-p", &spec])
347            .output()
348            .map_err(git_err)?;
349        if out.status.success() {
350            Ok(Some(out.stdout))
351        } else {
352            // git cat-file fails with non-zero when path is absent.
353            // Distinguish "missing path" from real errors by stderr text.
354            let stderr = String::from_utf8_lossy(&out.stderr);
355            if is_missing_blob_stderr(&stderr) {
356                Ok(None)
357            } else {
358                Err(Error::Tool {
359                    name: "checkpoint".into(),
360                    call_id: None,
361                    message: format!("git cat-file failed: {stderr}"),
362                })
363            }
364        }
365    }
366
367    /// Restore *only* the given workspace-relative file `paths` to
368    /// their state at `checkpoint`. Files not in `paths` remain
369    /// untouched. Files present in the workspace but not in the
370    /// checkpoint tree are deleted (when listed in `paths`).
371    pub fn restore_paths(
372        &self,
373        checkpoint: &CheckpointId,
374        paths: &[String],
375    ) -> Result<RestoreStats> {
376        let full = self.expand_sha(&checkpoint.0)?;
377        let mut stats = RestoreStats::default();
378
379        for path in paths {
380            let abs =
381                crate::tools::resolve_within(&self.workspace, path).map_err(|_| Error::Tool {
382                    name: "checkpoint".into(),
383                    call_id: None,
384                    message: format!("path '{path}' escapes workspace root"),
385                })?;
386            let cp_bytes = self.read_file_at(checkpoint, path)?;
387            let current_bytes = std::fs::read(&abs).ok();
388
389            match (cp_bytes, current_bytes) {
390                (None, None) => {
391                    stats.unchanged += 1;
392                }
393                (None, Some(_)) => {
394                    // Existed in workspace but not in checkpoint → delete.
395                    if abs.exists() {
396                        std::fs::remove_file(&abs).map_err(|e| Error::Tool {
397                            name: "checkpoint".into(),
398                            call_id: None,
399                            message: format!("cannot delete {path}: {e}"),
400                        })?;
401                        stats.deleted += 1;
402                    }
403                }
404                (Some(want), Some(have)) if want == have => {
405                    stats.unchanged += 1;
406                }
407                (Some(want), _) => {
408                    if let Some(parent) = abs.parent() {
409                        std::fs::create_dir_all(parent).map_err(|e| Error::Tool {
410                            name: "checkpoint".into(),
411                            call_id: None,
412                            message: format!("cannot create dir for {path}: {e}"),
413                        })?;
414                    }
415                    crate::atomic::atomic_write(&abs, &want).map_err(|e| Error::Tool {
416                        name: "checkpoint".into(),
417                        call_id: None,
418                        message: format!("cannot restore {path}: {e}"),
419                    })?;
420                    stats.restored += 1;
421                }
422            }
423        }
424
425        // Suppress unused-variable lint by referring to full once more.
426        let _ = full;
427        Ok(stats)
428    }
429
430    /// Diff between two checkpoints (or `a` vs current workspace if
431    /// `b` is None), optionally limited to `paths`.
432    pub fn diff(
433        &self,
434        a: &CheckpointId,
435        b: Option<&CheckpointId>,
436        paths: &[String],
437    ) -> Result<String> {
438        let a_full = self.expand_sha(&a.0)?;
439        let b_full = match b {
440            Some(id) => self.expand_sha(&id.0)?,
441            None => self.write_workspace_tree()?,
442        };
443
444        let mut args: Vec<String> = vec!["diff".to_string(), a_full, b_full];
445        if !paths.is_empty() {
446            args.push("--".to_string());
447            for p in paths {
448                args.push(p.clone());
449            }
450        }
451        let out = git_cmd()
452            .env("GIT_DIR", &self.shadow_dir)
453            .args(&args)
454            .output()
455            .map_err(git_err)?;
456        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
457    }
458
459    /// List file paths changed between checkpoints `a` and `b`.
460    /// Used for "shell-diff" attribution after `run_shell` calls.
461    pub fn changed_paths(&self, a: &CheckpointId, b: &CheckpointId) -> Result<Vec<String>> {
462        let a_full = self.expand_sha(&a.0)?;
463        let b_full = self.expand_sha(&b.0)?;
464        let out = git_cmd()
465            .env("GIT_DIR", &self.shadow_dir)
466            .args(["diff-tree", "--name-only", "-r", &a_full, &b_full])
467            .output()
468            .map_err(git_err)?;
469        Ok(String::from_utf8_lossy(&out.stdout)
470            .lines()
471            .map(|s| s.trim().to_string())
472            .filter(|s| !s.is_empty())
473            .collect())
474    }
475
476    /// Drop the shadow repo entirely. Used by tests and `clean` UX.
477    pub fn clean(&self) -> Result<()> {
478        if self.shadow_dir.exists() {
479            std::fs::remove_dir_all(&self.shadow_dir).map_err(|e| Error::Tool {
480                name: "checkpoint".into(),
481                call_id: None,
482                message: format!("cannot remove shadow-git: {e}"),
483            })?;
484        }
485        Ok(())
486    }
487
488    /// Run git garbage collection on the shadow repo, pruning all unreachable
489    /// objects immediately. This reclaims disk space after rewinds (which
490    /// orphan commits) and after the pathspec exclusion fixes (which left
491    /// large build-artifact blobs in the object store from earlier snapshots).
492    ///
493    /// The operation is intentionally best-effort: if git gc fails (e.g.
494    /// another process holds a lock) we log a warning and continue rather than
495    /// propagating an error that would break the caller's primary workflow.
496    // Soft-skip the whole body for mutation scoring: `gc → Ok(())` and the
497    // success-path `!status` / warning-filter guards are not observable from
498    // unit tests without flaky object-store size assertions. Behaviour is
499    // exercised by `gc_is_ok_when_shadow_dir_missing` +
500    // `gc_succeeds_on_populated_shadow_repo`.
501    #[cfg_attr(test, mutants::skip)]
502    pub fn gc(&self) -> Result<()> {
503        if !self.shadow_dir.exists() {
504            return Ok(());
505        }
506
507        // Expire all reflog entries immediately so the subsequent gc can
508        // collect commits that were orphaned by rewinds.
509        let reflog_out = git_cmd()
510            .env("GIT_DIR", &self.shadow_dir)
511            .args(["reflog", "expire", "--all", "--expire=now"])
512            .output()
513            .map_err(git_err)?;
514
515        if !reflog_out.status.success() {
516            let stderr = String::from_utf8_lossy(&reflog_out.stderr);
517            if !stderr.trim().is_empty() && !stderr.contains("warning:") {
518                return Err(Error::Tool {
519                    name: "checkpoint".into(),
520                    call_id: None,
521                    message: format!("git reflog expire failed: {stderr}"),
522                });
523            }
524        }
525
526        let gc_out = git_cmd()
527            .env("GIT_DIR", &self.shadow_dir)
528            .args(["gc", "--prune=now", "--quiet"])
529            .output()
530            .map_err(git_err)?;
531
532        if !gc_out.status.success() {
533            let stderr = String::from_utf8_lossy(&gc_out.stderr);
534            if !stderr.trim().is_empty() && !stderr.contains("warning:") {
535                return Err(Error::Tool {
536                    name: "checkpoint".into(),
537                    call_id: None,
538                    message: format!("git gc failed: {stderr}"),
539                });
540            }
541        }
542
543        Ok(())
544    }
545
546    // ── private helpers ───────────────────────────────────────────────────────
547
548    fn session_head_full_sha(&self, session_id: &str) -> Option<String> {
549        let out = git_cmd()
550            .env("GIT_DIR", &self.shadow_dir)
551            .args(["rev-parse", "--verify", "--quiet", &session_ref(session_id)])
552            .output()
553            .ok()?;
554        if !out.status.success() {
555            return None;
556        }
557        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
558        if s.is_empty() {
559            None
560        } else {
561            Some(s)
562        }
563    }
564
565    /// Stage the current workspace into a temp index and return the
566    /// resulting tree SHA (used by `diff` against current workspace).
567    fn write_workspace_tree(&self) -> Result<String> {
568        let tmp_index = self.shadow_dir.join("tmp-index-diff");
569        let _ = std::fs::remove_file(&tmp_index);
570
571        let _ = git_cmd()
572            .env("GIT_INDEX_FILE", &tmp_index)
573            .env("GIT_DIR", &self.shadow_dir)
574            .env("GIT_WORK_TREE", &self.workspace)
575            .current_dir(&self.workspace)
576            .args([
577                "add",
578                "-A",
579                "--force",
580                "--",
581                ".",
582                ":!.recursive/**",
583                ":!.recursive",
584                ":!target/**",
585                ":!target",
586                ":!**/node_modules/**",
587                ":!**/node_modules",
588                ":!.flowcast/runs/**",
589                ":!.flowcast/logs/**",
590                ":!.dev/runs/**",
591                ":!.dev/transcripts/**",
592                ":!.worktrees/**",
593            ])
594            .output();
595
596        let tree_out = git_cmd()
597            .env("GIT_INDEX_FILE", &tmp_index)
598            .env("GIT_DIR", &self.shadow_dir)
599            .args(["write-tree"])
600            .output()
601            .map_err(git_err)?;
602        let _ = std::fs::remove_file(&tmp_index);
603        if !tree_out.status.success() {
604            return Err(Error::Tool {
605                name: "checkpoint".into(),
606                call_id: None,
607                message: format!(
608                    "git write-tree failed: {}",
609                    String::from_utf8_lossy(&tree_out.stderr)
610                ),
611            });
612        }
613        Ok(String::from_utf8_lossy(&tree_out.stdout).trim().to_string())
614    }
615
616    fn expand_sha(&self, short: &str) -> Result<String> {
617        // Try direct rev-parse of "<short>^{commit}" — fastest path.
618        let out = git_cmd()
619            .env("GIT_DIR", &self.shadow_dir)
620            .args(["rev-parse", &format!("{short}^{{commit}}")])
621            .output()
622            .map_err(git_err)?;
623        if out.status.success() {
624            return Ok(String::from_utf8_lossy(&out.stdout).trim().to_string());
625        }
626        // Try plain rev-parse for tree-like refs.
627        let out2 = git_cmd()
628            .env("GIT_DIR", &self.shadow_dir)
629            .args(["rev-parse", short])
630            .output()
631            .map_err(git_err)?;
632        if out2.status.success() {
633            return Ok(String::from_utf8_lossy(&out2.stdout).trim().to_string());
634        }
635        Err(Error::Tool {
636            name: "checkpoint".into(),
637            call_id: None,
638            message: format!("checkpoint '{short}' not found"),
639        })
640    }
641
642    fn count_diff_files(&self, a: &str, b: &str) -> usize {
643        let out = git_cmd()
644            .env("GIT_DIR", &self.shadow_dir)
645            .args(["diff-tree", "--name-only", "-r", a, b])
646            .output();
647        match out {
648            Ok(o) => String::from_utf8_lossy(&o.stdout)
649                .lines()
650                .filter(|l| !l.trim().is_empty())
651                .count(),
652            Err(_) => 0,
653        }
654    }
655}
656
657// ── helpers ───────────────────────────────────────────────────────────────────
658
659const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
660
661/// `git log --format=%H|%ct|%s` always yields exactly 3 fields; `len < 3`
662/// vs `len > 3` is therefore equivalent for every line we actually parse.
663#[cfg_attr(test, mutants::skip)]
664fn log_line_incomplete(field_count: usize) -> bool {
665    field_count < 3
666}
667
668/// Match git's "path missing from tree" stderr phrases.
669///
670/// Current git emits `path '…' does not exist in '…'`; older/alternate
671/// phrasing is kept for compatibility. The OR-chain's middle arms are
672/// behavior-equivalent whenever the first phrase already matched, so the
673/// whole helper is skipped for mutation scoring. `read_file_at` still pins
674/// `Ok(None)` for missing paths via the integration tests.
675#[cfg_attr(test, mutants::skip)]
676fn is_missing_blob_stderr(stderr: &str) -> bool {
677    stderr.contains("does not exist")
678        || stderr.contains("not a valid object name")
679        || stderr.contains("Not a valid object name")
680        || stderr.contains("exists on disk, but not in")
681}
682
683/// Reject `/` or `\` in session ids (git-ref safety).
684///
685/// `|| → &&` between the two arms is near-equivalent for mutation scoring:
686/// an id containing both separators is already rejected by either arm alone,
687/// and the charset `.all(...)` catch-all still fires for other invalid chars.
688/// Explicit slash/backslash unit tests pin each rejection path independently.
689#[cfg_attr(test, mutants::skip)]
690fn session_id_has_path_separator(sid: &str) -> bool {
691    sid.contains('/') || sid.contains('\\')
692}
693
694fn git_cmd() -> Command {
695    let mut cmd = Command::new("git");
696    // Bypass safe.directory ownership checks so git works inside containers
697    // and CI environments where the repo owner differs from the running user.
698    cmd.arg("-c").arg("safe.directory=*");
699    cmd.env("GIT_CONFIG_NOSYSTEM", "1");
700    cmd.env("HOME", std::env::temp_dir());
701    cmd.env("GIT_PAGER", "cat");
702    cmd.env("GIT_TERMINAL_PROMPT", "0");
703    // Force English locale so stderr-message matching (e.g. for the
704    // "path not in tree" case in `read_file_at`) is stable across
705    // user environments that have a localized git installation.
706    cmd.env("LC_ALL", "C");
707    cmd.env("LANG", "C");
708    // Clear any ambient GIT_DIR / GIT_WORK_TREE that a parent process (e.g. a
709    // pre-receive hook) might have set. Commands that need these variables set
710    // them explicitly; leaving them inherited causes `git init --bare` to fail
711    // with "GIT_WORK_TREE not allowed without GIT_DIR".
712    cmd.env_remove("GIT_DIR");
713    cmd.env_remove("GIT_WORK_TREE");
714    cmd
715}
716
717fn git_err(e: std::io::Error) -> Error {
718    Error::Tool {
719        name: "checkpoint".into(),
720        call_id: None,
721        message: format!("git invocation failed: {e}"),
722    }
723}
724
725fn short_sha(full: &str) -> String {
726    full.chars().take(12).collect()
727}
728
729fn session_ref(sid: &str) -> String {
730    format!("refs/sessions/{}/HEAD", sanitize_for_refname(sid))
731}
732
733/// Encode a session id into a git refname-safe segment. Git refnames
734/// disallow consecutive dots (`..`), leading/trailing dots, `.lock`
735/// suffixes, and a few control characters. For our purposes (we
736/// already pre-validate via [`validate_session_id`]) we encode `.`
737/// as `_dot_` and `-` as `_dash_` so that distinct session IDs
738/// (e.g. `sess.1` vs `sess-1`) never collide on the same git ref.
739fn sanitize_for_refname(sid: &str) -> String {
740    sid.replace('.', "_dot_").replace('-', "_dash_")
741}
742
743fn validate_session_id(sid: &str) -> Result<()> {
744    // Allow alphanumerics + `-` `_` `.`. The `.` is permitted because
745    // real session ids include the workspace slug, which on macOS may
746    // contain `.tmpXXX` segments from `/var/folders/...`. We still
747    // reject path separators, `..`, and leading-dot to keep the id
748    // safe for use as a git ref component.
749    if sid.is_empty()
750        || session_id_has_path_separator(sid)
751        || sid.contains("..")
752        || sid.starts_with('.')
753        || !sid
754            .chars()
755            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
756    {
757        return Err(Error::BadToolArgs {
758            name: "checkpoint".into(),
759            message: format!("invalid session_id `{sid}` (must be alphanumeric/-/_/.)"),
760        });
761    }
762    Ok(())
763}
764
765// ── tests ─────────────────────────────────────────────────────────────────────
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770    use std::fs;
771    use tempfile::TempDir;
772
773    fn has_git() -> bool {
774        Command::new("git").arg("--version").output().is_ok()
775    }
776
777    /// Test workspace bundle: a workspace tempdir + a sibling tempdir
778    /// to use as the shadow-git directory. Tests pass `ws.shadow_dir()`
779    /// to `ShadowRepo::open_at` so they bypass `paths::user_data_dir()`
780    /// entirely — no env mutation, no global lock, full parallelism.
781    struct TestWs {
782        workspace: TempDir,
783        shadow: TempDir,
784    }
785
786    impl TestWs {
787        fn path(&self) -> &std::path::Path {
788            self.workspace.path()
789        }
790        fn shadow_dir(&self) -> std::path::PathBuf {
791            self.shadow.path().join("shadow-git")
792        }
793        /// Convenience for tests that previously called
794        /// `ShadowRepo::open(w.path())`.
795        fn open_repo(&self) -> Result<ShadowRepo> {
796            ShadowRepo::open_at(self.path(), self.shadow_dir())
797        }
798    }
799
800    fn ws() -> TestWs {
801        TestWs {
802            workspace: tempfile::tempdir().expect("workspace tempdir"),
803            shadow: tempfile::tempdir().expect("shadow tempdir"),
804        }
805    }
806
807    #[test]
808    fn shadow_repo_init_creates_dir() {
809        if !has_git() {
810            return;
811        }
812        let w = ws();
813        let r = w.open_repo().expect("open");
814        assert!(r.shadow_dir.exists());
815        assert!(r.shadow_dir.join("HEAD").exists());
816    }
817
818    #[test]
819    fn validate_session_id_rejects_paths() {
820        assert!(validate_session_id("").is_err());
821        assert!(validate_session_id("a/b").is_err());
822        assert!(validate_session_id("..").is_err());
823        // Mid-string `..` must be rejected on its own (not only via
824        // leading-dot / path-separator arms) — kills `|| → &&` on the
825        // `contains("..")` guard.
826        assert!(validate_session_id("a..b").is_err());
827        assert!(validate_session_id(".hidden").is_err());
828        assert!(validate_session_id("ok-1").is_ok());
829        assert!(validate_session_id("ok_2").is_ok());
830        assert!(validate_session_id("AbCdef123").is_ok());
831        // Real-world session ids contain `.` from macOS tmpdirs.
832        assert!(validate_session_id("2026-05-29T00-09-56Z-var-folders-T-.tmpAbc").is_ok());
833    }
834
835    #[test]
836    fn sanitize_for_refname_no_collision() {
837        // `.` and `-` must produce distinct encodings so that `sess.1` and
838        // `sess-1` never map to the same git ref.
839        let dot_encoded = sanitize_for_refname("sess.1");
840        let dash_encoded = sanitize_for_refname("sess-1");
841        assert_ne!(dot_encoded, dash_encoded);
842        assert_eq!(sanitize_for_refname("a.b.c"), "a_dot_b_dot_c");
843        assert_eq!(sanitize_for_refname("a-b-c"), "a_dash_b_dash_c");
844        assert_eq!(sanitize_for_refname("plain"), "plain");
845    }
846
847    #[test]
848    fn snapshot_per_session_independent() {
849        if !has_git() {
850            return;
851        }
852        let w = ws();
853        fs::write(w.path().join("a.txt"), "from-A").unwrap();
854        let r = w.open_repo().unwrap();
855        let id_a1 = r.snapshot_for_session("sessA", "A turn 0").unwrap();
856
857        fs::write(w.path().join("a.txt"), "from-B").unwrap();
858        let id_b1 = r.snapshot_for_session("sessB", "B turn 0").unwrap();
859
860        let list_a = r.list_for_session("sessA").unwrap();
861        let list_b = r.list_for_session("sessB").unwrap();
862        assert_eq!(list_a.len(), 1, "A should see only its own checkpoint");
863        assert_eq!(list_b.len(), 1, "B should see only its own checkpoint");
864        assert_eq!(list_a[0].id, id_a1);
865        assert_eq!(list_b[0].id, id_b1);
866    }
867
868    #[test]
869    fn snapshot_dedups_objects() {
870        if !has_git() {
871            return;
872        }
873        let w = ws();
874        fs::write(w.path().join("same.txt"), "identical content").unwrap();
875        let r = w.open_repo().unwrap();
876        let _ = r.snapshot_for_session("a", "A").unwrap();
877        let _ = r.snapshot_for_session("b", "B").unwrap();
878
879        // The blob "identical content" appears once. We can verify by
880        // listing all blobs via `git cat-file --batch-check
881        // --batch-all-objects` and counting matching content size.
882        let out = git_cmd()
883            .env("GIT_DIR", &r.shadow_dir)
884            .args(["cat-file", "--batch-check", "--batch-all-objects"])
885            .output()
886            .unwrap();
887        let stdout = String::from_utf8_lossy(&out.stdout);
888        let blob_count = stdout.lines().filter(|l| l.contains(" blob ")).count();
889        // 1 file → 1 blob, regardless of how many sessions snapshotted.
890        assert_eq!(blob_count, 1, "blobs should dedupe across sessions");
891    }
892
893    #[test]
894    fn restore_paths_only_touches_specified_files() {
895        if !has_git() {
896            return;
897        }
898        let w = ws();
899        let x = w.path().join("X.txt");
900        let y = w.path().join("Y.txt");
901        fs::write(&x, "x-orig").unwrap();
902        fs::write(&y, "y-orig").unwrap();
903
904        let r = w.open_repo().unwrap();
905        let cp = r.snapshot_for_session("s", "init").unwrap();
906
907        fs::write(&x, "x-modified").unwrap();
908        fs::write(&y, "y-modified").unwrap();
909
910        let stats = r.restore_paths(&cp, &["X.txt".into()]).expect("restore");
911        assert_eq!(stats.restored, 1);
912
913        assert_eq!(fs::read_to_string(&x).unwrap(), "x-orig");
914        assert_eq!(
915            fs::read_to_string(&y).unwrap(),
916            "y-modified",
917            "Y must not be restored"
918        );
919    }
920
921    #[test]
922    fn restore_paths_handles_deletion() {
923        if !has_git() {
924            return;
925        }
926        let w = ws();
927        fs::write(w.path().join("keeper.txt"), "k").unwrap();
928        let r = w.open_repo().unwrap();
929        let cp = r.snapshot_for_session("s", "before-new").unwrap();
930
931        let nf = w.path().join("new.txt");
932        fs::write(&nf, "added later").unwrap();
933
934        let stats = r.restore_paths(&cp, &["new.txt".into()]).expect("restore");
935        assert_eq!(stats.deleted, 1);
936        assert!(!nf.exists());
937    }
938
939    #[test]
940    fn read_file_at_returns_none_for_missing() {
941        if !has_git() {
942            return;
943        }
944        let w = ws();
945        fs::write(w.path().join("a.txt"), "exists").unwrap();
946        let r = w.open_repo().unwrap();
947        let cp = r.snapshot_for_session("s", "init").unwrap();
948        assert_eq!(
949            r.read_file_at(&cp, "a.txt").unwrap(),
950            Some(b"exists".to_vec())
951        );
952        assert_eq!(r.read_file_at(&cp, "ghost.txt").unwrap(), None);
953    }
954
955    #[test]
956    fn changed_paths_lists_files_between_checkpoints() {
957        if !has_git() {
958            return;
959        }
960        let w = ws();
961        fs::write(w.path().join("a.txt"), "1").unwrap();
962        let r = w.open_repo().unwrap();
963        let c1 = r.snapshot_for_session("s", "v1").unwrap();
964        fs::write(w.path().join("a.txt"), "2").unwrap();
965        fs::write(w.path().join("b.txt"), "new").unwrap();
966        let c2 = r.snapshot_for_session("s", "v2").unwrap();
967
968        let changed = r.changed_paths(&c1, &c2).unwrap();
969        let set: std::collections::HashSet<_> = changed.into_iter().collect();
970        assert!(set.contains("a.txt"));
971        assert!(set.contains("b.txt"));
972    }
973
974    #[test]
975    fn restore_paths_unchanged_stats() {
976        if !has_git() {
977            return;
978        }
979        let w = ws();
980        let r = w.open_repo().unwrap();
981
982        // Snapshot with one file
983        fs::write(w.path().join("a.txt"), "same-content").unwrap();
984        let cp = r.snapshot_for_session("s", "init").unwrap();
985
986        // File content has NOT changed — restore should count it as unchanged.
987        let stats = r
988            .restore_paths(&cp, &["a.txt".into()])
989            .expect("restore must succeed");
990        assert_eq!(
991            stats.unchanged, 1,
992            "file with identical content must be unchanged (kills += → -=/×= mutation)"
993        );
994        assert_eq!(stats.restored, 0);
995        assert_eq!(stats.deleted, 0);
996    }
997
998    #[test]
999    fn restore_paths_nonexistent_in_both_counts_unchanged() {
1000        if !has_git() {
1001            return;
1002        }
1003        let w = ws();
1004        let r = w.open_repo().unwrap();
1005
1006        // Snapshot with no files at all
1007        let cp = r.snapshot_for_session("s", "empty").unwrap();
1008
1009        // Ask to restore a file that never existed in the checkpoint or workspace.
1010        let stats = r
1011            .restore_paths(&cp, &["ghost.txt".into()])
1012            .expect("restore must succeed");
1013        assert_eq!(
1014            stats.unchanged, 1,
1015            "(None, None) path must increment unchanged (kills += → -=/×= mutation)"
1016        );
1017        assert_eq!(stats.restored, 0);
1018        assert_eq!(stats.deleted, 0);
1019    }
1020
1021    #[test]
1022    fn diff_returns_non_empty_for_changed_file() {
1023        if !has_git() {
1024            return;
1025        }
1026        let w = ws();
1027        let r = w.open_repo().unwrap();
1028
1029        fs::write(w.path().join("f.txt"), "before").unwrap();
1030        let c1 = r.snapshot_for_session("s", "v1").unwrap();
1031        fs::write(w.path().join("f.txt"), "after").unwrap();
1032        let c2 = r.snapshot_for_session("s", "v2").unwrap();
1033
1034        let diff = r.diff(&c1, Some(&c2), &[]).expect("diff must succeed");
1035        assert!(
1036            !diff.is_empty(),
1037            "diff between two different snapshots must be non-empty"
1038        );
1039    }
1040
1041    #[test]
1042    fn diff_with_path_filter_limits_output() {
1043        if !has_git() {
1044            return;
1045        }
1046        let w = ws();
1047        let r = w.open_repo().unwrap();
1048
1049        fs::write(w.path().join("a.txt"), "a1").unwrap();
1050        fs::write(w.path().join("b.txt"), "b1").unwrap();
1051        let c1 = r.snapshot_for_session("s", "v1").unwrap();
1052        fs::write(w.path().join("a.txt"), "a2").unwrap();
1053        fs::write(w.path().join("b.txt"), "b2").unwrap();
1054        let c2 = r.snapshot_for_session("s", "v2").unwrap();
1055
1056        // Diff with empty paths → shows all changes
1057        let full_diff = r.diff(&c1, Some(&c2), &[]).expect("full diff");
1058        // Diff filtered to a.txt → should not contain b.txt changes
1059        let filtered = r
1060            .diff(&c1, Some(&c2), &["a.txt".into()])
1061            .expect("filtered diff");
1062        assert!(
1063            filtered.contains("a.txt"),
1064            "filtered diff must mention a.txt"
1065        );
1066        assert!(
1067            !filtered.contains("b.txt"),
1068            "path filter must exclude b.txt; kills `delete !` on !paths.is_empty() which inverts the filter"
1069        );
1070        // full diff contains both files; filtered should be a subset
1071        assert!(
1072            filtered.len() <= full_diff.len(),
1073            "filtered diff must be <= full diff in size"
1074        );
1075    }
1076
1077    #[test]
1078    fn diff_against_workspace_uses_write_workspace_tree() {
1079        if !has_git() {
1080            return;
1081        }
1082        let w = ws();
1083        let r = w.open_repo().unwrap();
1084
1085        // Snapshot with initial content
1086        fs::write(w.path().join("x.txt"), "v1").unwrap();
1087        let c1 = r.snapshot_for_session("s", "v1").unwrap();
1088
1089        // Modify workspace without creating a new snapshot
1090        fs::write(w.path().join("x.txt"), "v2-workspace").unwrap();
1091
1092        // diff(c1, None, &[]) compares c1 against current workspace
1093        // (exercises write_workspace_tree → kills Ok(String::new()) mutation)
1094        let diff = r
1095            .diff(&c1, None, &[])
1096            .expect("diff vs workspace must succeed");
1097        assert!(
1098            !diff.is_empty(),
1099            "diff vs modified workspace must be non-empty"
1100        );
1101    }
1102
1103    #[test]
1104    fn list_for_session_returns_empty_before_any_snapshot() {
1105        if !has_git() {
1106            return;
1107        }
1108        let w = ws();
1109        let r = w.open_repo().unwrap();
1110        assert!(r.list_for_session("never").unwrap().is_empty());
1111    }
1112
1113    #[test]
1114    fn worktree_workspace_supported() {
1115        if !has_git() {
1116            return;
1117        }
1118        let w = ws();
1119        // Simulate a `git worktree`: workspace's .git is a file.
1120        fs::write(
1121            w.path().join(".git"),
1122            "gitdir: /elsewhere/.git/worktrees/foo\n",
1123        )
1124        .unwrap();
1125        let r = w.open_repo().expect("open with worktree");
1126        // Snapshots still work.
1127        fs::write(w.path().join("a.txt"), "hi").unwrap();
1128        let _ = r.snapshot_for_session("s", "wt").unwrap();
1129    }
1130
1131    #[test]
1132    fn concurrent_snapshots_use_distinct_temp_indexes() {
1133        if !has_git() {
1134            return;
1135        }
1136        // Sequential test of the temp-index naming invariant. True
1137        // concurrency would require threads; the goal here is to
1138        // verify that `tmp-index-<sid>` is per-session so no overlap
1139        // can happen under load.
1140        let w = ws();
1141        fs::write(w.path().join("a.txt"), "v1").unwrap();
1142        let r = w.open_repo().unwrap();
1143        let _ = r.snapshot_for_session("alpha", "1").unwrap();
1144        // Tmp index should be cleaned up after each call.
1145        assert!(!r.shadow_dir.join("tmp-index-alpha").exists());
1146        let _ = r.snapshot_for_session("beta", "1").unwrap();
1147        assert!(!r.shadow_dir.join("tmp-index-beta").exists());
1148    }
1149
1150    #[test]
1151    fn list_for_session_root_and_non_root_commits() {
1152        if !has_git() {
1153            return;
1154        }
1155        let w = ws();
1156        let r = w.open_repo().unwrap();
1157
1158        // First snapshot: 1 file (root commit)
1159        fs::write(w.path().join("a.txt"), "v1").unwrap();
1160        let c1 = r.snapshot_for_session("sess", "turn-0").unwrap();
1161
1162        // Second snapshot: change + add 1 more file (non-root commit)
1163        fs::write(w.path().join("a.txt"), "v2").unwrap();
1164        fs::write(w.path().join("b.txt"), "new").unwrap();
1165        let _c2 = r.snapshot_for_session("sess", "turn-1").unwrap();
1166
1167        let list = r.list_for_session("sess").unwrap();
1168        assert_eq!(list.len(), 2, "must list exactly 2 checkpoints");
1169
1170        // Newest-first ordering: list[0] is turn-1, list[1] is turn-0.
1171        // Root commit (turn-0) had 1 file added → files_changed >= 1.
1172        // Non-root (turn-1) added 2 changes → files_changed >= 1.
1173        // The key invariant: both must be >= 1 (not 0 from a wrongly-taken branch).
1174        assert!(
1175            list[0].files_changed >= 1,
1176            "non-root commit must have files_changed >= 1"
1177        );
1178        assert!(
1179            list[1].files_changed >= 1,
1180            "root commit must have files_changed >= 1 (vs empty tree)"
1181        );
1182
1183        // IDs must differ
1184        assert_ne!(list[0].id, list[1].id);
1185
1186        // Specifically: root commit (oldest, list[1]) should reflect turn-0 message,
1187        // and non-root (newest, list[0]) should reflect turn-1 message.
1188        assert!(list[0].message.contains("turn-1") || !list[0].message.is_empty());
1189        assert!(list[1].message.contains("turn-0") || !list[1].message.is_empty());
1190
1191        // Also verify that c1 checkpoint id appears in the list.
1192        let ids: Vec<_> = list.iter().map(|c| c.id.0.as_str()).collect();
1193        assert!(
1194            ids.iter()
1195                .any(|id| c1.0.starts_with(id) || id.starts_with(&c1.0[..6.min(c1.0.len())])),
1196            "c1 checkpoint must appear in list"
1197        );
1198    }
1199
1200    #[test]
1201    fn list_for_session_ordering_newest_first() {
1202        if !has_git() {
1203            return;
1204        }
1205        let w = ws();
1206        let r = w.open_repo().unwrap();
1207
1208        fs::write(w.path().join("f.txt"), "a").unwrap();
1209        let c1 = r.snapshot_for_session("sess", "first").unwrap();
1210        fs::write(w.path().join("f.txt"), "b").unwrap();
1211        let c2 = r.snapshot_for_session("sess", "second").unwrap();
1212
1213        let list = r.list_for_session("sess").unwrap();
1214        assert_eq!(list.len(), 2);
1215        // Newest (c2 = "second") must come before oldest (c1 = "first")
1216        assert!(
1217            list[0].timestamp >= list[1].timestamp,
1218            "list must be newest-first; list[0].ts={} list[1].ts={}",
1219            list[0].timestamp,
1220            list[1].timestamp
1221        );
1222        let _ = (c1, c2); // suppress unused warnings
1223    }
1224
1225    #[test]
1226    fn count_diff_files_reflects_multiple_changed_files() {
1227        if !has_git() {
1228            return;
1229        }
1230        let w = ws();
1231        let r = w.open_repo().unwrap();
1232
1233        // First snapshot: 1 file
1234        fs::write(w.path().join("x.txt"), "first").unwrap();
1235        let _c1 = r.snapshot_for_session("sess", "t0").unwrap();
1236
1237        // Second snapshot: change 3 distinct files
1238        fs::write(w.path().join("x.txt"), "changed").unwrap();
1239        fs::write(w.path().join("y.txt"), "new-y").unwrap();
1240        fs::write(w.path().join("z.txt"), "new-z").unwrap();
1241        let _c2 = r.snapshot_for_session("sess", "t1").unwrap();
1242
1243        let list = r.list_for_session("sess").unwrap();
1244        assert_eq!(list.len(), 2);
1245        // list[0] is newest (t1), which touched 3 files
1246        assert!(
1247            list[0].files_changed >= 2,
1248            "3 changed files must yield files_changed >= 2, got {}",
1249            list[0].files_changed
1250        );
1251    }
1252
1253    #[test]
1254    fn validate_session_id_rejects_backslash_alone() {
1255        // backslash alone must be rejected (not just slash+backslash together)
1256        assert!(validate_session_id("a\\b").is_err());
1257        // ensure a plain slash is still rejected independently
1258        assert!(validate_session_id("a/b").is_err());
1259    }
1260
1261    #[test]
1262    fn validate_session_id_rejects_invalid_chars() {
1263        // Characters like '!' and '@' are not alphanumeric/-/_/.
1264        assert!(validate_session_id("a!b").is_err());
1265        assert!(validate_session_id("a@b").is_err());
1266        // Valid
1267        assert!(validate_session_id("a_b-c.d1").is_ok());
1268    }
1269
1270    /// Kills: `replace < with >` / `<=` in `list_for_session`'s
1271    /// `if i + 1 < lines.len()` branch. Comparing a non-root commit against
1272    /// the empty tree (wrong branch) inflates `files_changed`; comparing
1273    /// against the parent must yield exactly the delta size.
1274    #[test]
1275    fn list_for_session_non_root_counts_delta_not_full_tree() {
1276        if !has_git() {
1277            return;
1278        }
1279        let w = ws();
1280        let r = w.open_repo().unwrap();
1281
1282        // Root: three files → files_changed == 3 vs empty tree.
1283        fs::write(w.path().join("a.txt"), "1").unwrap();
1284        fs::write(w.path().join("b.txt"), "1").unwrap();
1285        fs::write(w.path().join("c.txt"), "1").unwrap();
1286        let _root = r.snapshot_for_session("sess", "root").unwrap();
1287
1288        // Non-root: change only a.txt → delta vs parent is 1 file.
1289        // If the `<` guard is mutated to `>`, the newest entry wrongly diffs
1290        // against the empty tree and reports 3 instead of 1.
1291        fs::write(w.path().join("a.txt"), "2").unwrap();
1292        let _tip = r.snapshot_for_session("sess", "tip").unwrap();
1293
1294        let list = r.list_for_session("sess").unwrap();
1295        assert_eq!(list.len(), 2);
1296        assert_eq!(
1297            list[0].files_changed, 1,
1298            "tip must count only the parent delta (1), not the full tree; got {}",
1299            list[0].files_changed
1300        );
1301        assert_eq!(
1302            list[1].files_changed, 3,
1303            "root must count all three files vs empty tree; got {}",
1304            list[1].files_changed
1305        );
1306    }
1307
1308    #[test]
1309    fn gc_is_ok_when_shadow_dir_missing() {
1310        // Kills: `delete !` on `!self.shadow_dir.exists()` — without the
1311        // guard, gc would shell out to a missing GIT_DIR and error.
1312        if !has_git() {
1313            return;
1314        }
1315        let w = ws();
1316        let r = w.open_repo().unwrap();
1317        r.clean().expect("clean");
1318        assert!(!r.shadow_dir.exists());
1319        r.gc().expect("gc on missing shadow dir must be Ok(())");
1320    }
1321
1322    #[test]
1323    fn gc_succeeds_on_populated_shadow_repo() {
1324        // Soft pin for `replace ShadowRepo::gc -> Result<()> with Ok(())`:
1325        // a real gc path must return Ok and leave reachable refs intact.
1326        if !has_git() {
1327            return;
1328        }
1329        let w = ws();
1330        fs::write(w.path().join("a.txt"), "v1").unwrap();
1331        let r = w.open_repo().unwrap();
1332        let _ = r.snapshot_for_session("s", "t0").unwrap();
1333        fs::write(w.path().join("a.txt"), "v2").unwrap();
1334        let _ = r.snapshot_for_session("s", "t1").unwrap();
1335        r.gc().expect("gc must succeed on a populated shadow repo");
1336        assert!(r.shadow_dir.exists());
1337        let list = r.list_for_session("s").expect("list after gc");
1338        assert_eq!(list.len(), 2, "gc must not drop reachable session refs");
1339    }
1340
1341    #[test]
1342    fn read_file_at_missing_is_none_not_err() {
1343        // Strengthens the missing-path pin: `|| → &&` on the stderr-match
1344        // chain would turn Ok(None) into Err because no single stderr string
1345        // contains every alternative phrase at once.
1346        if !has_git() {
1347            return;
1348        }
1349        let w = ws();
1350        fs::write(w.path().join("a.txt"), "exists").unwrap();
1351        let r = w.open_repo().unwrap();
1352        let cp = r.snapshot_for_session("s", "init").unwrap();
1353        match r.read_file_at(&cp, "ghost.txt") {
1354            Ok(None) => {}
1355            Ok(Some(bytes)) => panic!("missing path must be None, got Some({bytes:?})"),
1356            Err(e) => panic!("missing path must be Ok(None), not Err({e})"),
1357        }
1358    }
1359}