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 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 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 pub fn workspace(&self) -> &Path {
123 &self.workspace
124 }
125
126 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 let _ = std::fs::remove_file(&tmp_index);
137
138 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 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 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 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 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 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 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 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 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 let _ = full;
382 Ok(stats)
383 }
384
385 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 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 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 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 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 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 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
541const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
544
545fn git_cmd() -> Command {
546 let mut cmd = Command::new("git");
547 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 cmd.env("LC_ALL", "C");
558 cmd.env("LANG", "C");
559 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
583fn sanitize_for_refname(sid: &str) -> String {
589 sid.replace('.', "-")
590}
591
592fn validate_session_id(sid: &str) -> Result<()> {
593 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#[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 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 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 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 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 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 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 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 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 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}