Skip to main content

sr_ai/git/
mod.rs

1use anyhow::{Context, Result, bail};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::process::Command;
5
6pub struct GitRepo {
7    root: PathBuf,
8}
9
10#[allow(dead_code)]
11impl GitRepo {
12    pub fn discover() -> Result<Self> {
13        let output = Command::new("git")
14            .args(["rev-parse", "--show-toplevel"])
15            .output()
16            .context("failed to run git")?;
17
18        if !output.status.success() {
19            bail!(crate::error::SrAiError::NotAGitRepo);
20        }
21
22        let root = String::from_utf8(output.stdout)
23            .context("invalid utf-8 from git")?
24            .trim()
25            .into();
26
27        Ok(Self { root })
28    }
29
30    pub fn root(&self) -> &PathBuf {
31        &self.root
32    }
33
34    fn git(&self, args: &[&str]) -> Result<String> {
35        let output = Command::new("git")
36            .args(["-C", self.root.to_str().unwrap()])
37            .args(args)
38            .output()
39            .with_context(|| format!("failed to run git {}", args.join(" ")))?;
40
41        if !output.status.success() {
42            let stderr = String::from_utf8_lossy(&output.stderr);
43            bail!(crate::error::SrAiError::GitCommand(format!(
44                "git {} failed: {}",
45                args.join(" "),
46                stderr.trim()
47            )));
48        }
49
50        Ok(String::from_utf8_lossy(&output.stdout).to_string())
51    }
52
53    fn git_allow_failure(&self, args: &[&str]) -> Result<(bool, String)> {
54        let output = Command::new("git")
55            .args(["-C", self.root.to_str().unwrap()])
56            .args(args)
57            .output()
58            .with_context(|| format!("failed to run git {}", args.join(" ")))?;
59
60        Ok((
61            output.status.success(),
62            String::from_utf8_lossy(&output.stdout).to_string(),
63        ))
64    }
65
66    pub fn has_staged_changes(&self) -> Result<bool> {
67        let out = self.git(&["diff", "--cached", "--name-only"])?;
68        Ok(!out.trim().is_empty())
69    }
70
71    pub fn has_any_changes(&self) -> Result<bool> {
72        let out = self.git(&["status", "--porcelain"])?;
73        Ok(!out.trim().is_empty())
74    }
75
76    pub fn has_head(&self) -> Result<bool> {
77        let (ok, _) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
78        Ok(ok)
79    }
80
81    pub fn reset_head(&self) -> Result<()> {
82        if self.has_head()? {
83            self.git(&["reset", "HEAD", "--quiet"])?;
84        } else {
85            // Fresh repo with no commits — unstage via rm --cached
86            let _ = self.git_allow_failure(&["rm", "--cached", "-r", ".", "--quiet"]);
87        }
88        Ok(())
89    }
90
91    pub fn stage_file(&self, file: &str) -> Result<bool> {
92        let full_path = self.root.join(file);
93        let exists = full_path.exists();
94
95        if !exists {
96            // Check if it's a deleted file
97            let out = self.git(&["ls-files", "--deleted"])?;
98            let is_deleted = out.lines().any(|l| l.trim() == file);
99            if !is_deleted {
100                return Ok(false);
101            }
102        }
103
104        let (ok, _) = self.git_allow_failure(&["add", "--", file])?;
105        Ok(ok)
106    }
107
108    pub fn has_staged_after_add(&self) -> Result<bool> {
109        self.has_staged_changes()
110    }
111
112    pub fn commit(&self, message: &str) -> Result<()> {
113        let output = Command::new("git")
114            .args(["-C", self.root.to_str().unwrap()])
115            .args(["commit", "-F", "-"])
116            .stdin(std::process::Stdio::piped())
117            .stdout(std::process::Stdio::piped())
118            .stderr(std::process::Stdio::piped())
119            .spawn()
120            .context("failed to spawn git commit")?;
121
122        use std::io::Write;
123        let mut child = output;
124        if let Some(mut stdin) = child.stdin.take() {
125            stdin.write_all(message.as_bytes())?;
126        }
127
128        let out = child.wait_with_output()?;
129        if !out.status.success() {
130            let stderr = String::from_utf8_lossy(&out.stderr);
131            bail!(crate::error::SrAiError::GitCommand(format!(
132                "git commit failed: {}",
133                stderr.trim()
134            )));
135        }
136
137        Ok(())
138    }
139
140    pub fn recent_commits(&self, count: usize) -> Result<String> {
141        self.git(&["--no-pager", "log", "--oneline", &format!("-{count}")])
142    }
143
144    pub fn diff_cached(&self) -> Result<String> {
145        self.git(&["diff", "--cached"])
146    }
147
148    pub fn diff_cached_stat(&self) -> Result<String> {
149        self.git(&["diff", "--cached", "--stat"])
150    }
151
152    pub fn diff_head(&self) -> Result<String> {
153        let (ok, out) = self.git_allow_failure(&["diff", "HEAD"])?;
154        if ok { Ok(out) } else { self.git(&["diff"]) }
155    }
156
157    pub fn status_porcelain(&self) -> Result<String> {
158        self.git(&["status", "--porcelain"])
159    }
160
161    pub fn untracked_files(&self) -> Result<String> {
162        self.git(&["ls-files", "--others", "--exclude-standard"])
163    }
164
165    pub fn show(&self, rev: &str) -> Result<String> {
166        self.git(&["show", rev])
167    }
168
169    pub fn log_range(&self, base: &str, count: Option<usize>) -> Result<String> {
170        let mut args = vec!["--no-pager", "log", "--oneline"];
171        let count_str;
172        if let Some(n) = count {
173            count_str = format!("-{n}");
174            args.push(&count_str);
175        }
176        args.push(base);
177        self.git(&args)
178    }
179
180    pub fn diff_range(&self, base: &str) -> Result<String> {
181        self.git(&["diff", base])
182    }
183
184    pub fn current_branch(&self) -> Result<String> {
185        let out = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
186        Ok(out.trim().to_string())
187    }
188
189    pub fn head_short(&self) -> Result<String> {
190        let out = self.git(&["rev-parse", "--short", "HEAD"])?;
191        Ok(out.trim().to_string())
192    }
193
194    /// Count commits since the last tag. If no tags exist, counts all commits.
195    pub fn commits_since_last_tag(&self) -> Result<usize> {
196        // Try to find the most recent tag
197        let (ok, tag) = self.git_allow_failure(&["describe", "--tags", "--abbrev=0"])?;
198        let tag = tag.trim();
199
200        let out = if ok && !tag.is_empty() {
201            self.git(&["rev-list", &format!("{tag}..HEAD"), "--count"])?
202        } else {
203            self.git(&["rev-list", "HEAD", "--count"])?
204        };
205
206        out.trim()
207            .parse::<usize>()
208            .context("failed to parse commit count")
209    }
210
211    /// Get detailed log of recent commits (SHA, subject, body) oldest first.
212    pub fn log_detailed(&self, count: usize) -> Result<String> {
213        let out = self.git(&[
214            "--no-pager",
215            "log",
216            "--reverse",
217            &format!("-{count}"),
218            "--format=%h %s%n%b%n---",
219        ])?;
220        Ok(out)
221    }
222
223    pub fn file_statuses(&self) -> Result<HashMap<String, char>> {
224        let out = self.git(&["status", "--porcelain"])?;
225        let mut map = HashMap::new();
226        for line in out.lines() {
227            if line.len() < 3 {
228                continue;
229            }
230            let xy = &line.as_bytes()[..2];
231            let mut path = line[3..].to_string();
232            if let Some(pos) = path.find(" -> ") {
233                path = path[pos + 4..].to_string();
234            }
235            let (x, y) = (xy[0], xy[1]);
236            let status = match (x, y) {
237                (b'?', b'?') => 'A',
238                (b'A', _) | (_, b'A') => 'A',
239                (b'D', _) | (_, b'D') => 'D',
240                (b'R', _) | (_, b'R') => 'R',
241                (b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => 'M',
242                _ => '~',
243            };
244            map.insert(path, status);
245        }
246        Ok(map)
247    }
248
249    /// Create a snapshot of the working tree state into the platform data directory.
250    /// Location: `<data_local_dir>/sr/snapshots/<repo-hash>/`
251    ///   - macOS:   ~/Library/Application Support/sr/snapshots/<hash>/
252    ///   - Linux:   ~/.local/share/sr/snapshots/<hash>/
253    ///   - Windows: %LOCALAPPDATA%/sr/snapshots/<hash>/
254    ///
255    /// The snapshot directly copies every changed/added/deleted file into
256    /// `files/` alongside a `manifest.json` that records each file's status
257    /// and whether it was staged. This avoids git-stash entirely — restore
258    /// is a plain file copy that cannot conflict.
259    ///
260    /// Lives completely outside the repo so the agent cannot touch it.
261    pub fn snapshot_working_tree(&self) -> Result<PathBuf> {
262        let snapshot_dir = snapshot_dir_for(&self.root)
263            .context("failed to resolve snapshot directory (no data directory available)")?;
264        // Start fresh — remove any prior snapshot for this repo
265        if snapshot_dir.exists() {
266            std::fs::remove_dir_all(&snapshot_dir).ok();
267        }
268        std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot directory")?;
269
270        let files_dir = snapshot_dir.join("files");
271        std::fs::create_dir_all(&files_dir)?;
272
273        // Record which repo this snapshot belongs to
274        std::fs::write(
275            snapshot_dir.join("repo_root"),
276            self.root.to_string_lossy().as_bytes(),
277        )
278        .context("failed to write repo_root")?;
279
280        // Record current HEAD so we can reset if partial commits were made
281        let (has_head, head_ref) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
282        if has_head {
283            std::fs::write(snapshot_dir.join("head_ref"), head_ref.trim())
284                .context("failed to write head_ref")?;
285        }
286
287        // Build manifest: every file that shows up in `git status --porcelain`
288        // gets its content copied and its status recorded.
289        let porcelain = self.git(&["status", "--porcelain"])?;
290        let staged_names = self.git(&["diff", "--cached", "--name-only"])?;
291        let staged_set: std::collections::HashSet<&str> = staged_names
292            .lines()
293            .map(|l| l.trim())
294            .filter(|l| !l.is_empty())
295            .collect();
296
297        #[derive(serde::Serialize, serde::Deserialize)]
298        struct ManifestEntry {
299            path: String,
300            /// X (index) status character from porcelain
301            index_status: char,
302            /// Y (worktree) status character from porcelain
303            worktree_status: char,
304            /// Whether the file was staged at snapshot time
305            staged: bool,
306            /// Whether a file copy exists in the snapshot (false for deletions)
307            has_content: bool,
308        }
309
310        let mut manifest: Vec<ManifestEntry> = Vec::new();
311
312        for line in porcelain.lines() {
313            if line.len() < 3 {
314                continue;
315            }
316            let bytes = line.as_bytes();
317            let x = bytes[0] as char;
318            let y = bytes[1] as char;
319            let mut path = line[3..].to_string();
320            // Handle renames: "R  old -> new"
321            if let Some(pos) = path.find(" -> ") {
322                path = path[pos + 4..].to_string();
323            }
324
325            let src = self.root.join(&path);
326            let has_content = src.exists() && src.is_file();
327
328            if has_content {
329                let dest = files_dir.join(&path);
330                if let Some(parent) = dest.parent() {
331                    std::fs::create_dir_all(parent).ok();
332                }
333                if let Err(e) = std::fs::copy(&src, &dest) {
334                    eprintln!("warning: failed to snapshot {path}: {e}");
335                }
336            }
337
338            manifest.push(ManifestEntry {
339                staged: staged_set.contains(path.as_str()),
340                path,
341                index_status: x,
342                worktree_status: y,
343                has_content,
344            });
345        }
346
347        let manifest_json =
348            serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")?;
349        std::fs::write(snapshot_dir.join("manifest.json"), manifest_json)
350            .context("failed to write manifest.json")?;
351
352        // Mark snapshot as valid
353        let now = std::time::SystemTime::now()
354            .duration_since(std::time::UNIX_EPOCH)
355            .unwrap_or_default()
356            .as_secs();
357        std::fs::write(snapshot_dir.join("timestamp"), now.to_string())
358            .context("failed to write timestamp")?;
359
360        Ok(snapshot_dir)
361    }
362
363    /// Restore working tree from the latest snapshot.
364    ///
365    /// 1. Reset HEAD to the original commit (undoes any partial commits)
366    /// 2. Clean the index
367    /// 3. Copy every snapshotted file back from `files/`
368    /// 4. Delete files that were deleted at snapshot time
369    /// 5. Re-stage files that were staged at snapshot time
370    ///
371    /// This is a plain file copy — no git-stash, no merge conflicts.
372    pub fn restore_snapshot(&self) -> Result<()> {
373        let snapshot_dir = self.snapshot_dir()?;
374        if !snapshot_dir.join("timestamp").exists() {
375            bail!("no valid snapshot found");
376        }
377
378        let files_dir = snapshot_dir.join("files");
379
380        // Step 1: Reset HEAD to pre-operation state
381        let head_ref_path = snapshot_dir.join("head_ref");
382        if head_ref_path.exists() {
383            let original_head = std::fs::read_to_string(&head_ref_path)?;
384            let original_head = original_head.trim();
385            if !original_head.is_empty() {
386                let _ = self.git_allow_failure(&["reset", "--soft", original_head]);
387            }
388        }
389
390        // Step 2: Clean the index
391        self.reset_head()?;
392
393        // Step 3-5: Restore files from manifest
394        let manifest_path = snapshot_dir.join("manifest.json");
395        if !manifest_path.exists() {
396            bail!("snapshot manifest.json missing — cannot restore");
397        }
398
399        #[derive(serde::Deserialize)]
400        struct ManifestEntry {
401            path: String,
402            index_status: char,
403            worktree_status: char,
404            staged: bool,
405            has_content: bool,
406        }
407
408        let manifest_data = std::fs::read_to_string(&manifest_path)?;
409        let manifest: Vec<ManifestEntry> =
410            serde_json::from_str(&manifest_data).context("failed to parse snapshot manifest")?;
411
412        let mut restored = 0usize;
413        let mut failed = 0usize;
414
415        for entry in &manifest {
416            let dest = self.root.join(&entry.path);
417
418            if entry.has_content {
419                // Restore file content from snapshot copy
420                let src = files_dir.join(&entry.path);
421                if src.exists() {
422                    if let Some(parent) = dest.parent() {
423                        std::fs::create_dir_all(parent).ok();
424                    }
425                    match std::fs::copy(&src, &dest) {
426                        Ok(_) => restored += 1,
427                        Err(e) => {
428                            eprintln!("warning: failed to restore {}: {e}", entry.path);
429                            failed += 1;
430                        }
431                    }
432                } else {
433                    eprintln!("warning: snapshot missing content for {}", entry.path);
434                    failed += 1;
435                }
436            } else if entry.index_status == 'D' || entry.worktree_status == 'D' {
437                // File was deleted at snapshot time — ensure it stays deleted
438                if dest.exists() {
439                    std::fs::remove_file(&dest).ok();
440                }
441            }
442
443            // Re-stage if it was staged at snapshot time
444            if entry.staged {
445                let _ = self.git_allow_failure(&["add", "--", &entry.path]);
446            }
447        }
448
449        if failed > 0 {
450            eprintln!("sr: restored {restored} files, {failed} failed");
451        }
452
453        Ok(())
454    }
455
456    /// Remove the snapshot after a successful operation.
457    pub fn clear_snapshot(&self) {
458        if let Ok(dir) = self.snapshot_dir() {
459            let _ = std::fs::remove_dir_all(&dir);
460        }
461    }
462
463    /// Returns the snapshot directory path for this repo.
464    pub fn snapshot_dir(&self) -> Result<PathBuf> {
465        snapshot_dir_for(&self.root)
466            .context("failed to resolve snapshot directory (no data directory available)")
467    }
468
469    /// Check if a valid snapshot exists.
470    pub fn has_snapshot(&self) -> bool {
471        self.snapshot_dir()
472            .map(|d| d.join("timestamp").exists())
473            .unwrap_or(false)
474    }
475}
476
477/// Resolve the snapshot directory for a repo root.
478/// `<data_local_dir>/sr/snapshots/<repo-hash>/`
479fn snapshot_dir_for(repo_root: &std::path::Path) -> Option<PathBuf> {
480    let base = dirs::data_local_dir()?;
481    let repo_id =
482        &crate::cache::fingerprint::sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
483    Some(base.join("sr").join("snapshots").join(repo_id))
484}
485
486/// Guard that ensures the snapshot is cleaned up on success
487/// and restored on failure (drop without explicit success).
488pub struct SnapshotGuard<'a> {
489    repo: &'a GitRepo,
490    succeeded: bool,
491}
492
493impl<'a> SnapshotGuard<'a> {
494    /// Create a snapshot and return the guard.
495    pub fn new(repo: &'a GitRepo) -> Result<Self> {
496        repo.snapshot_working_tree()?;
497        Ok(Self {
498            repo,
499            succeeded: false,
500        })
501    }
502
503    /// Mark the operation as successful — snapshot will be cleared on drop.
504    pub fn success(mut self) {
505        self.succeeded = true;
506        self.repo.clear_snapshot();
507    }
508}
509
510impl Drop for SnapshotGuard<'_> {
511    fn drop(&mut self) {
512        if !self.succeeded && self.repo.has_snapshot() {
513            eprintln!("sr: operation failed, restoring working tree from snapshot...");
514            if let Err(e) = self.repo.restore_snapshot() {
515                eprintln!("sr: warning: snapshot restore failed: {e}");
516                if let Ok(dir) = self.repo.snapshot_dir() {
517                    eprintln!(
518                        "sr: snapshot preserved at {} for manual recovery",
519                        dir.display()
520                    );
521                }
522            } else {
523                self.repo.clear_snapshot();
524            }
525        }
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use std::fs;
533
534    /// Create a temporary git repo with an initial commit and return a GitRepo.
535    fn temp_repo() -> (tempfile::TempDir, GitRepo) {
536        let dir = tempfile::tempdir().unwrap();
537        let root = dir.path().to_path_buf();
538
539        let git = |args: &[&str]| {
540            Command::new("git")
541                .args(["-C", root.to_str().unwrap()])
542                .args(args)
543                .output()
544                .unwrap()
545        };
546
547        git(&["init"]);
548        git(&["config", "user.email", "test@test.com"]);
549        git(&["config", "user.name", "Test"]);
550        // Initial commit so HEAD exists
551        fs::write(root.join("init.txt"), "init").unwrap();
552        git(&["add", "init.txt"]);
553        git(&["commit", "-m", "initial"]);
554
555        let repo = GitRepo { root };
556        (dir, repo)
557    }
558
559    #[test]
560    fn snapshot_creates_manifest_with_staged_files() {
561        let (_dir, repo) = temp_repo();
562
563        // Create and stage a new file
564        fs::write(repo.root.join("new.go"), "package main").unwrap();
565        repo.git(&["add", "new.go"]).unwrap();
566
567        let snap_dir = repo.snapshot_working_tree().unwrap();
568
569        // Manifest should exist
570        let manifest_path = snap_dir.join("manifest.json");
571        assert!(manifest_path.exists(), "manifest.json should exist");
572
573        let data = fs::read_to_string(&manifest_path).unwrap();
574        assert!(data.contains("new.go"), "manifest should list new.go");
575        assert!(
576            data.contains("\"staged\": true"),
577            "new.go should be marked staged"
578        );
579
580        // File copy should exist
581        assert!(
582            snap_dir.join("files/new.go").exists(),
583            "file content should be copied"
584        );
585        assert_eq!(
586            fs::read_to_string(snap_dir.join("files/new.go")).unwrap(),
587            "package main"
588        );
589
590        // HEAD ref should be recorded
591        assert!(snap_dir.join("head_ref").exists());
592
593        repo.clear_snapshot();
594    }
595
596    #[test]
597    fn snapshot_restore_recovers_staged_new_files() {
598        let (_dir, repo) = temp_repo();
599
600        // Stage two new files
601        fs::write(repo.root.join("a.go"), "package a").unwrap();
602        fs::write(repo.root.join("b.go"), "package b").unwrap();
603        repo.git(&["add", "a.go", "b.go"]).unwrap();
604
605        repo.snapshot_working_tree().unwrap();
606
607        // Simulate what execute_plan does: reset head, stage partially, commit
608        repo.reset_head().unwrap();
609        repo.git(&["add", "a.go"]).unwrap();
610        repo.git(&["commit", "-m", "partial"]).unwrap();
611
612        // Now restore — should undo the partial commit and recover both files staged
613        repo.restore_snapshot().unwrap();
614
615        // Both files should exist
616        assert!(repo.root.join("a.go").exists());
617        assert!(repo.root.join("b.go").exists());
618        assert_eq!(
619            fs::read_to_string(repo.root.join("a.go")).unwrap(),
620            "package a"
621        );
622        assert_eq!(
623            fs::read_to_string(repo.root.join("b.go")).unwrap(),
624            "package b"
625        );
626
627        // Both should be staged
628        let staged = repo.git(&["diff", "--cached", "--name-only"]).unwrap();
629        assert!(staged.contains("a.go"), "a.go should be re-staged");
630        assert!(staged.contains("b.go"), "b.go should be re-staged");
631
632        // The partial commit should be gone
633        let log = repo.git(&["log", "--oneline"]).unwrap();
634        assert!(
635            !log.contains("partial"),
636            "partial commit should be undone by HEAD reset"
637        );
638
639        repo.clear_snapshot();
640    }
641
642    #[test]
643    fn snapshot_restore_with_dirty_index_does_not_conflict() {
644        let (_dir, repo) = temp_repo();
645
646        // Stage a new file
647        fs::write(repo.root.join("file.rs"), "fn main() {}").unwrap();
648        repo.git(&["add", "file.rs"]).unwrap();
649
650        repo.snapshot_working_tree().unwrap();
651
652        // Simulate partial staging left by a failed execute_plan
653        repo.reset_head().unwrap();
654        repo.git(&["add", "file.rs"]).unwrap();
655        // Don't commit — index is dirty with the same file
656
657        // Restore should NOT fail (this was the original bug)
658        let result = repo.restore_snapshot();
659        assert!(
660            result.is_ok(),
661            "restore should succeed with dirty index: {result:?}"
662        );
663
664        assert_eq!(
665            fs::read_to_string(repo.root.join("file.rs")).unwrap(),
666            "fn main() {}"
667        );
668
669        repo.clear_snapshot();
670    }
671
672    #[test]
673    fn snapshot_handles_modified_files() {
674        let (_dir, repo) = temp_repo();
675
676        // Modify an existing tracked file
677        fs::write(repo.root.join("init.txt"), "modified content").unwrap();
678        repo.git(&["add", "init.txt"]).unwrap();
679
680        repo.snapshot_working_tree().unwrap();
681
682        // Simulate: reset and make a different change
683        repo.reset_head().unwrap();
684        fs::write(repo.root.join("init.txt"), "wrong content").unwrap();
685
686        // Restore should bring back the original modified content
687        repo.restore_snapshot().unwrap();
688
689        assert_eq!(
690            fs::read_to_string(repo.root.join("init.txt")).unwrap(),
691            "modified content"
692        );
693
694        repo.clear_snapshot();
695    }
696
697    #[test]
698    fn snapshot_guard_restores_on_drop() {
699        let (_dir, repo) = temp_repo();
700
701        fs::write(repo.root.join("guarded.txt"), "important").unwrap();
702        repo.git(&["add", "guarded.txt"]).unwrap();
703
704        {
705            let _guard = SnapshotGuard::new(&repo).unwrap();
706            // Simulate failure: reset and delete the file
707            repo.reset_head().unwrap();
708            fs::remove_file(repo.root.join("guarded.txt")).ok();
709            // Guard drops here without calling success()
710        }
711
712        // File should be restored
713        assert!(repo.root.join("guarded.txt").exists());
714        assert_eq!(
715            fs::read_to_string(repo.root.join("guarded.txt")).unwrap(),
716            "important"
717        );
718    }
719
720    #[test]
721    fn snapshot_guard_clears_on_success() {
722        let (_dir, repo) = temp_repo();
723
724        fs::write(repo.root.join("ok.txt"), "data").unwrap();
725        repo.git(&["add", "ok.txt"]).unwrap();
726
727        let guard = SnapshotGuard::new(&repo).unwrap();
728        assert!(repo.has_snapshot());
729        guard.success();
730
731        // Snapshot should be cleared
732        assert!(!repo.has_snapshot());
733    }
734}