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 path = line[3..].to_string();
232            let (x, y) = (xy[0], xy[1]);
233            let is_rename = matches!((x, y), (b'R', _) | (_, b'R'));
234            if is_rename {
235                if let Some(pos) = path.find(" -> ") {
236                    let old_path = path[..pos].to_string();
237                    let new_path = path[pos + 4..].to_string();
238                    map.insert(old_path, 'D');
239                    map.insert(new_path, 'R');
240                } else {
241                    map.insert(path, 'R');
242                }
243            } else {
244                let status = match (x, y) {
245                    (b'?', b'?') => 'A',
246                    (b'A', _) | (_, b'A') => 'A',
247                    (b'D', _) | (_, b'D') => 'D',
248                    (b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => 'M',
249                    _ => '~',
250                };
251                map.insert(path, status);
252            }
253        }
254        Ok(map)
255    }
256
257    /// Create a snapshot of the working tree state into the platform data directory.
258    /// Location: `<data_local_dir>/sr/snapshots/<repo-hash>/`
259    ///   - macOS:   ~/Library/Application Support/sr/snapshots/<hash>/
260    ///   - Linux:   ~/.local/share/sr/snapshots/<hash>/
261    ///   - Windows: %LOCALAPPDATA%/sr/snapshots/<hash>/
262    ///
263    /// The snapshot directly copies every changed/added/deleted file into
264    /// `files/` alongside a `manifest.json` that records each file's status
265    /// and whether it was staged. This avoids git-stash entirely — restore
266    /// is a plain file copy that cannot conflict.
267    ///
268    /// Lives completely outside the repo so the agent cannot touch it.
269    pub fn snapshot_working_tree(&self) -> Result<PathBuf> {
270        let snapshot_dir = snapshot_dir_for(&self.root)
271            .context("failed to resolve snapshot directory (no data directory available)")?;
272        // Start fresh — remove any prior snapshot for this repo
273        if snapshot_dir.exists() {
274            std::fs::remove_dir_all(&snapshot_dir).ok();
275        }
276        std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot directory")?;
277
278        let files_dir = snapshot_dir.join("files");
279        std::fs::create_dir_all(&files_dir)?;
280
281        // Record which repo this snapshot belongs to
282        std::fs::write(
283            snapshot_dir.join("repo_root"),
284            self.root.to_string_lossy().as_bytes(),
285        )
286        .context("failed to write repo_root")?;
287
288        // Record current HEAD so we can reset if partial commits were made
289        let (has_head, head_ref) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
290        if has_head {
291            std::fs::write(snapshot_dir.join("head_ref"), head_ref.trim())
292                .context("failed to write head_ref")?;
293        }
294
295        // Build manifest: every file that shows up in `git status --porcelain`
296        // gets its content copied and its status recorded.
297        let porcelain = self.git(&["status", "--porcelain"])?;
298        let staged_names = self.git(&["diff", "--cached", "--name-only"])?;
299        let staged_set: std::collections::HashSet<&str> = staged_names
300            .lines()
301            .map(|l| l.trim())
302            .filter(|l| !l.is_empty())
303            .collect();
304
305        #[derive(serde::Serialize, serde::Deserialize)]
306        struct ManifestEntry {
307            path: String,
308            /// X (index) status character from porcelain
309            index_status: char,
310            /// Y (worktree) status character from porcelain
311            worktree_status: char,
312            /// Whether the file was staged at snapshot time
313            staged: bool,
314            /// Whether a file copy exists in the snapshot (false for deletions)
315            has_content: bool,
316        }
317
318        let mut manifest: Vec<ManifestEntry> = Vec::new();
319
320        for line in porcelain.lines() {
321            if line.len() < 3 {
322                continue;
323            }
324            let bytes = line.as_bytes();
325            let x = bytes[0] as char;
326            let y = bytes[1] as char;
327            let mut path = line[3..].to_string();
328            // Handle renames: "R  old -> new"
329            if let Some(pos) = path.find(" -> ") {
330                path = path[pos + 4..].to_string();
331            }
332
333            let src = self.root.join(&path);
334            let has_content = src.exists() && src.is_file();
335
336            if has_content {
337                let dest = files_dir.join(&path);
338                if let Some(parent) = dest.parent() {
339                    std::fs::create_dir_all(parent).ok();
340                }
341                if let Err(e) = std::fs::copy(&src, &dest) {
342                    eprintln!("warning: failed to snapshot {path}: {e}");
343                }
344            }
345
346            manifest.push(ManifestEntry {
347                staged: staged_set.contains(path.as_str()),
348                path,
349                index_status: x,
350                worktree_status: y,
351                has_content,
352            });
353        }
354
355        let manifest_json =
356            serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")?;
357        std::fs::write(snapshot_dir.join("manifest.json"), manifest_json)
358            .context("failed to write manifest.json")?;
359
360        // Mark snapshot as valid
361        let now = std::time::SystemTime::now()
362            .duration_since(std::time::UNIX_EPOCH)
363            .unwrap_or_default()
364            .as_secs();
365        std::fs::write(snapshot_dir.join("timestamp"), now.to_string())
366            .context("failed to write timestamp")?;
367
368        Ok(snapshot_dir)
369    }
370
371    /// Restore working tree from the latest snapshot.
372    ///
373    /// 1. Reset HEAD to the original commit (undoes any partial commits)
374    /// 2. Clean the index
375    /// 3. Copy every snapshotted file back from `files/`
376    /// 4. Delete files that were deleted at snapshot time
377    /// 5. Re-stage files that were staged at snapshot time
378    ///
379    /// This is a plain file copy — no git-stash, no merge conflicts.
380    pub fn restore_snapshot(&self) -> Result<()> {
381        let snapshot_dir = self.snapshot_dir()?;
382        if !snapshot_dir.join("timestamp").exists() {
383            bail!("no valid snapshot found");
384        }
385
386        let files_dir = snapshot_dir.join("files");
387
388        // Step 1: Reset HEAD to pre-operation state
389        let head_ref_path = snapshot_dir.join("head_ref");
390        if head_ref_path.exists() {
391            let original_head = std::fs::read_to_string(&head_ref_path)?;
392            let original_head = original_head.trim();
393            if !original_head.is_empty() {
394                let _ = self.git_allow_failure(&["reset", "--soft", original_head]);
395            }
396        }
397
398        // Step 2: Clean the index
399        self.reset_head()?;
400
401        // Step 3-5: Restore files from manifest
402        let manifest_path = snapshot_dir.join("manifest.json");
403        if !manifest_path.exists() {
404            bail!("snapshot manifest.json missing — cannot restore");
405        }
406
407        #[derive(serde::Deserialize)]
408        struct ManifestEntry {
409            path: String,
410            index_status: char,
411            worktree_status: char,
412            staged: bool,
413            has_content: bool,
414        }
415
416        let manifest_data = std::fs::read_to_string(&manifest_path)?;
417        let manifest: Vec<ManifestEntry> =
418            serde_json::from_str(&manifest_data).context("failed to parse snapshot manifest")?;
419
420        let mut restored = 0usize;
421        let mut failed = 0usize;
422
423        for entry in &manifest {
424            let dest = self.root.join(&entry.path);
425
426            if entry.has_content {
427                // Restore file content from snapshot copy
428                let src = files_dir.join(&entry.path);
429                if src.exists() {
430                    if let Some(parent) = dest.parent() {
431                        std::fs::create_dir_all(parent).ok();
432                    }
433                    match std::fs::copy(&src, &dest) {
434                        Ok(_) => restored += 1,
435                        Err(e) => {
436                            eprintln!("warning: failed to restore {}: {e}", entry.path);
437                            failed += 1;
438                        }
439                    }
440                } else {
441                    eprintln!("warning: snapshot missing content for {}", entry.path);
442                    failed += 1;
443                }
444            } else if entry.index_status == 'D' || entry.worktree_status == 'D' {
445                // File was deleted at snapshot time — ensure it stays deleted
446                if dest.exists() {
447                    std::fs::remove_file(&dest).ok();
448                }
449            }
450
451            // Re-stage if it was staged at snapshot time
452            if entry.staged {
453                let _ = self.git_allow_failure(&["add", "--", &entry.path]);
454            }
455        }
456
457        if failed > 0 {
458            eprintln!("sr: restored {restored} files, {failed} failed");
459        }
460
461        Ok(())
462    }
463
464    /// Remove the snapshot after a successful operation.
465    pub fn clear_snapshot(&self) {
466        if let Ok(dir) = self.snapshot_dir() {
467            let _ = std::fs::remove_dir_all(&dir);
468        }
469    }
470
471    /// Returns the snapshot directory path for this repo.
472    pub fn snapshot_dir(&self) -> Result<PathBuf> {
473        snapshot_dir_for(&self.root)
474            .context("failed to resolve snapshot directory (no data directory available)")
475    }
476
477    /// Check if a valid snapshot exists.
478    pub fn has_snapshot(&self) -> bool {
479        self.snapshot_dir()
480            .map(|d| d.join("timestamp").exists())
481            .unwrap_or(false)
482    }
483}
484
485/// Resolve the snapshot directory for a repo root.
486/// `<data_local_dir>/sr/snapshots/<repo-hash>/`
487fn snapshot_dir_for(repo_root: &std::path::Path) -> Option<PathBuf> {
488    let base = dirs::data_local_dir()?;
489    let repo_id =
490        &crate::cache::fingerprint::sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
491    Some(base.join("sr").join("snapshots").join(repo_id))
492}
493
494/// Guard that ensures the snapshot is cleaned up on success
495/// and restored on failure (drop without explicit success).
496pub struct SnapshotGuard<'a> {
497    repo: &'a GitRepo,
498    succeeded: bool,
499}
500
501impl<'a> SnapshotGuard<'a> {
502    /// Create a snapshot and return the guard.
503    pub fn new(repo: &'a GitRepo) -> Result<Self> {
504        repo.snapshot_working_tree()?;
505        Ok(Self {
506            repo,
507            succeeded: false,
508        })
509    }
510
511    /// Mark the operation as successful — snapshot will be cleared on drop.
512    pub fn success(mut self) {
513        self.succeeded = true;
514        self.repo.clear_snapshot();
515    }
516}
517
518impl Drop for SnapshotGuard<'_> {
519    fn drop(&mut self) {
520        if !self.succeeded && self.repo.has_snapshot() {
521            eprintln!("sr: operation failed, restoring working tree from snapshot...");
522            if let Err(e) = self.repo.restore_snapshot() {
523                eprintln!("sr: warning: snapshot restore failed: {e}");
524                if let Ok(dir) = self.repo.snapshot_dir() {
525                    eprintln!(
526                        "sr: snapshot preserved at {} for manual recovery",
527                        dir.display()
528                    );
529                }
530            } else {
531                self.repo.clear_snapshot();
532            }
533        }
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use std::fs;
541
542    /// Create a temporary git repo with an initial commit and return a GitRepo.
543    fn temp_repo() -> (tempfile::TempDir, GitRepo) {
544        let dir = tempfile::tempdir().unwrap();
545        let root = dir.path().to_path_buf();
546
547        let git = |args: &[&str]| {
548            Command::new("git")
549                .args(["-C", root.to_str().unwrap()])
550                .args(args)
551                .output()
552                .unwrap()
553        };
554
555        git(&["init"]);
556        git(&["config", "user.email", "test@test.com"]);
557        git(&["config", "user.name", "Test"]);
558        // Initial commit so HEAD exists
559        fs::write(root.join("init.txt"), "init").unwrap();
560        git(&["add", "init.txt"]);
561        git(&["commit", "-m", "initial"]);
562
563        let repo = GitRepo { root };
564        (dir, repo)
565    }
566
567    #[test]
568    fn snapshot_creates_manifest_with_staged_files() {
569        let (_dir, repo) = temp_repo();
570
571        // Create and stage a new file
572        fs::write(repo.root.join("new.go"), "package main").unwrap();
573        repo.git(&["add", "new.go"]).unwrap();
574
575        let snap_dir = repo.snapshot_working_tree().unwrap();
576
577        // Manifest should exist
578        let manifest_path = snap_dir.join("manifest.json");
579        assert!(manifest_path.exists(), "manifest.json should exist");
580
581        let data = fs::read_to_string(&manifest_path).unwrap();
582        assert!(data.contains("new.go"), "manifest should list new.go");
583        assert!(
584            data.contains("\"staged\": true"),
585            "new.go should be marked staged"
586        );
587
588        // File copy should exist
589        assert!(
590            snap_dir.join("files/new.go").exists(),
591            "file content should be copied"
592        );
593        assert_eq!(
594            fs::read_to_string(snap_dir.join("files/new.go")).unwrap(),
595            "package main"
596        );
597
598        // HEAD ref should be recorded
599        assert!(snap_dir.join("head_ref").exists());
600
601        repo.clear_snapshot();
602    }
603
604    #[test]
605    fn snapshot_restore_recovers_staged_new_files() {
606        let (_dir, repo) = temp_repo();
607
608        // Stage two new files
609        fs::write(repo.root.join("a.go"), "package a").unwrap();
610        fs::write(repo.root.join("b.go"), "package b").unwrap();
611        repo.git(&["add", "a.go", "b.go"]).unwrap();
612
613        repo.snapshot_working_tree().unwrap();
614
615        // Simulate what execute_plan does: reset head, stage partially, commit
616        repo.reset_head().unwrap();
617        repo.git(&["add", "a.go"]).unwrap();
618        repo.git(&["commit", "-m", "partial"]).unwrap();
619
620        // Now restore — should undo the partial commit and recover both files staged
621        repo.restore_snapshot().unwrap();
622
623        // Both files should exist
624        assert!(repo.root.join("a.go").exists());
625        assert!(repo.root.join("b.go").exists());
626        assert_eq!(
627            fs::read_to_string(repo.root.join("a.go")).unwrap(),
628            "package a"
629        );
630        assert_eq!(
631            fs::read_to_string(repo.root.join("b.go")).unwrap(),
632            "package b"
633        );
634
635        // Both should be staged
636        let staged = repo.git(&["diff", "--cached", "--name-only"]).unwrap();
637        assert!(staged.contains("a.go"), "a.go should be re-staged");
638        assert!(staged.contains("b.go"), "b.go should be re-staged");
639
640        // The partial commit should be gone
641        let log = repo.git(&["log", "--oneline"]).unwrap();
642        assert!(
643            !log.contains("partial"),
644            "partial commit should be undone by HEAD reset"
645        );
646
647        repo.clear_snapshot();
648    }
649
650    #[test]
651    fn snapshot_restore_with_dirty_index_does_not_conflict() {
652        let (_dir, repo) = temp_repo();
653
654        // Stage a new file
655        fs::write(repo.root.join("file.rs"), "fn main() {}").unwrap();
656        repo.git(&["add", "file.rs"]).unwrap();
657
658        repo.snapshot_working_tree().unwrap();
659
660        // Simulate partial staging left by a failed execute_plan
661        repo.reset_head().unwrap();
662        repo.git(&["add", "file.rs"]).unwrap();
663        // Don't commit — index is dirty with the same file
664
665        // Restore should NOT fail (this was the original bug)
666        let result = repo.restore_snapshot();
667        assert!(
668            result.is_ok(),
669            "restore should succeed with dirty index: {result:?}"
670        );
671
672        assert_eq!(
673            fs::read_to_string(repo.root.join("file.rs")).unwrap(),
674            "fn main() {}"
675        );
676
677        repo.clear_snapshot();
678    }
679
680    #[test]
681    fn snapshot_handles_modified_files() {
682        let (_dir, repo) = temp_repo();
683
684        // Modify an existing tracked file
685        fs::write(repo.root.join("init.txt"), "modified content").unwrap();
686        repo.git(&["add", "init.txt"]).unwrap();
687
688        repo.snapshot_working_tree().unwrap();
689
690        // Simulate: reset and make a different change
691        repo.reset_head().unwrap();
692        fs::write(repo.root.join("init.txt"), "wrong content").unwrap();
693
694        // Restore should bring back the original modified content
695        repo.restore_snapshot().unwrap();
696
697        assert_eq!(
698            fs::read_to_string(repo.root.join("init.txt")).unwrap(),
699            "modified content"
700        );
701
702        repo.clear_snapshot();
703    }
704
705    #[test]
706    fn snapshot_guard_restores_on_drop() {
707        let (_dir, repo) = temp_repo();
708
709        fs::write(repo.root.join("guarded.txt"), "important").unwrap();
710        repo.git(&["add", "guarded.txt"]).unwrap();
711
712        {
713            let _guard = SnapshotGuard::new(&repo).unwrap();
714            // Simulate failure: reset and delete the file
715            repo.reset_head().unwrap();
716            fs::remove_file(repo.root.join("guarded.txt")).ok();
717            // Guard drops here without calling success()
718        }
719
720        // File should be restored
721        assert!(repo.root.join("guarded.txt").exists());
722        assert_eq!(
723            fs::read_to_string(repo.root.join("guarded.txt")).unwrap(),
724            "important"
725        );
726    }
727
728    #[test]
729    fn snapshot_guard_clears_on_success() {
730        let (_dir, repo) = temp_repo();
731
732        fs::write(repo.root.join("ok.txt"), "data").unwrap();
733        repo.git(&["add", "ok.txt"]).unwrap();
734
735        let guard = SnapshotGuard::new(&repo).unwrap();
736        assert!(repo.has_snapshot());
737        guard.success();
738
739        // Snapshot should be cleared
740        assert!(!repo.has_snapshot());
741    }
742
743    #[test]
744    fn file_statuses_includes_both_sides_of_rename() {
745        let (_dir, repo) = temp_repo();
746
747        // Create and commit a file
748        fs::write(repo.root.join("old_name.txt"), "content").unwrap();
749        repo.git(&["add", "old_name.txt"]).unwrap();
750        repo.git(&["commit", "-m", "add old_name"]).unwrap();
751
752        // Rename it via git mv
753        repo.git(&["mv", "old_name.txt", "new_name.txt"]).unwrap();
754
755        let statuses = repo.file_statuses().unwrap();
756
757        assert_eq!(
758            statuses.get("old_name.txt").copied(),
759            Some('D'),
760            "old path should appear as deleted"
761        );
762        assert_eq!(
763            statuses.get("new_name.txt").copied(),
764            Some('R'),
765            "new path should appear as renamed"
766        );
767    }
768}