1use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use crate::error::{Error, Result};
21
22#[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#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct CheckpointInfo {
37 pub id: CheckpointId,
38 pub message: String,
39 pub timestamp: i64,
41 pub files_changed: usize,
44}
45
46#[derive(Debug, Clone, Default)]
48pub struct RestoreStats {
49 pub restored: usize,
51 pub deleted: usize,
54 pub unchanged: usize,
56}
57
58#[derive(Debug, Clone)]
64pub struct ShadowRepo {
65 workspace: PathBuf,
66 shadow_dir: PathBuf,
67}
68
69impl ShadowRepo {
70 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 #[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 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 pub fn workspace(&self) -> &Path {
139 &self.workspace
140 }
141
142 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 let _ = std::fs::remove_file(&tmp_index);
153
154 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 ":!.recursive/**",
174 ":!.recursive",
175 ":!target/**",
180 ":!target",
181 ":!**/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 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 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 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 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 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 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 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 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 let _ = full;
427 Ok(stats)
428 }
429
430 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 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 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 #[cfg_attr(test, mutants::skip)]
502 pub fn gc(&self) -> Result<()> {
503 if !self.shadow_dir.exists() {
504 return Ok(());
505 }
506
507 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 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 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 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 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
657const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
660
661#[cfg_attr(test, mutants::skip)]
664fn log_line_incomplete(field_count: usize) -> bool {
665 field_count < 3
666}
667
668#[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#[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 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 cmd.env("LC_ALL", "C");
707 cmd.env("LANG", "C");
708 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
733fn sanitize_for_refname(sid: &str) -> String {
740 sid.replace('.', "_dot_").replace('-', "_dash_")
741}
742
743fn validate_session_id(sid: &str) -> Result<()> {
744 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#[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 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 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 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 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 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 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 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 fs::write(w.path().join("a.txt"), "same-content").unwrap();
984 let cp = r.snapshot_for_session("s", "init").unwrap();
985
986 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 let cp = r.snapshot_for_session("s", "empty").unwrap();
1008
1009 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 let full_diff = r.diff(&c1, Some(&c2), &[]).expect("full diff");
1058 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 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 fs::write(w.path().join("x.txt"), "v1").unwrap();
1087 let c1 = r.snapshot_for_session("s", "v1").unwrap();
1088
1089 fs::write(w.path().join("x.txt"), "v2-workspace").unwrap();
1091
1092 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 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 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 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 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 fs::write(w.path().join("a.txt"), "v1").unwrap();
1160 let c1 = r.snapshot_for_session("sess", "turn-0").unwrap();
1161
1162 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 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 assert_ne!(list[0].id, list[1].id);
1185
1186 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 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 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); }
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 fs::write(w.path().join("x.txt"), "first").unwrap();
1235 let _c1 = r.snapshot_for_session("sess", "t0").unwrap();
1236
1237 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 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 assert!(validate_session_id("a\\b").is_err());
1257 assert!(validate_session_id("a/b").is_err());
1259 }
1260
1261 #[test]
1262 fn validate_session_id_rejects_invalid_chars() {
1263 assert!(validate_session_id("a!b").is_err());
1265 assert!(validate_session_id("a@b").is_err());
1266 assert!(validate_session_id("a_b-c.d1").is_ok());
1268 }
1269
1270 #[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 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 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 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 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 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}