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
6/// Strip C-style quoting that git applies to paths containing spaces,
7/// non-ASCII characters, or other special bytes. Git wraps such paths
8/// in double quotes and uses backslash escapes (e.g. `\t`, `\n`, `\\`,
9/// `\"`, and octal `\NNN`).
10fn git_unquote(s: &str) -> String {
11    let s = s.trim();
12    if !(s.starts_with('"') && s.ends_with('"')) {
13        return s.to_string();
14    }
15    // Strip surrounding quotes
16    let inner = &s[1..s.len() - 1];
17    let mut out = Vec::new();
18    let bytes = inner.as_bytes();
19    let mut i = 0;
20    while i < bytes.len() {
21        if bytes[i] == b'\\' && i + 1 < bytes.len() {
22            i += 1;
23            match bytes[i] {
24                b'\\' => out.push(b'\\'),
25                b'"' => out.push(b'"'),
26                b'n' => out.push(b'\n'),
27                b't' => out.push(b'\t'),
28                b'r' => out.push(b'\r'),
29                b'a' => out.push(0x07),
30                b'b' => out.push(0x08),
31                b'f' => out.push(0x0C),
32                b'v' => out.push(0x0B),
33                // Octal escape: \NNN (1-3 digits)
34                b'0'..=b'3' => {
35                    let mut val = (bytes[i] - b'0') as u16;
36                    for _ in 0..2 {
37                        if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
38                            i += 1;
39                            val = val * 8 + (bytes[i] - b'0') as u16;
40                        } else {
41                            break;
42                        }
43                    }
44                    out.push(val as u8);
45                }
46                other => {
47                    out.push(b'\\');
48                    out.push(other);
49                }
50            }
51        } else {
52            out.push(bytes[i]);
53        }
54        i += 1;
55    }
56    String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).to_string())
57}
58
59pub struct GitRepo {
60    root: PathBuf,
61}
62
63#[allow(dead_code)]
64impl GitRepo {
65    pub fn discover() -> Result<Self> {
66        let output = Command::new("git")
67            .args(["rev-parse", "--show-toplevel"])
68            .output()
69            .context("failed to run git")?;
70
71        if !output.status.success() {
72            bail!(crate::error::SrAiError::NotAGitRepo);
73        }
74
75        let root = String::from_utf8(output.stdout)
76            .context("invalid utf-8 from git")?
77            .trim()
78            .into();
79
80        Ok(Self { root })
81    }
82
83    pub fn root(&self) -> &PathBuf {
84        &self.root
85    }
86
87    fn git(&self, args: &[&str]) -> Result<String> {
88        let output = Command::new("git")
89            .args(["-C", self.root.to_str().unwrap()])
90            .args(args)
91            .output()
92            .with_context(|| format!("failed to run git {}", args.join(" ")))?;
93
94        if !output.status.success() {
95            let stderr = String::from_utf8_lossy(&output.stderr);
96            bail!(crate::error::SrAiError::GitCommand(format!(
97                "git {} failed: {}",
98                args.join(" "),
99                stderr.trim()
100            )));
101        }
102
103        Ok(String::from_utf8_lossy(&output.stdout).to_string())
104    }
105
106    fn git_allow_failure(&self, args: &[&str]) -> Result<(bool, String)> {
107        let output = Command::new("git")
108            .args(["-C", self.root.to_str().unwrap()])
109            .args(args)
110            .output()
111            .with_context(|| format!("failed to run git {}", args.join(" ")))?;
112
113        Ok((
114            output.status.success(),
115            String::from_utf8_lossy(&output.stdout).to_string(),
116        ))
117    }
118
119    pub fn has_staged_changes(&self) -> Result<bool> {
120        let out = self.git(&["diff", "--cached", "--name-only"])?;
121        Ok(!out.trim().is_empty())
122    }
123
124    pub fn has_any_changes(&self) -> Result<bool> {
125        let out = self.git(&["status", "--porcelain"])?;
126        Ok(!out.trim().is_empty())
127    }
128
129    pub fn has_head(&self) -> Result<bool> {
130        let (ok, _) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
131        Ok(ok)
132    }
133
134    pub fn reset_head(&self) -> Result<()> {
135        if self.has_head()? {
136            self.git(&["reset", "HEAD", "--quiet"])?;
137        } else {
138            // Fresh repo with no commits — unstage via rm --cached
139            let _ = self.git_allow_failure(&["rm", "--cached", "-r", ".", "--quiet"]);
140        }
141        Ok(())
142    }
143
144    pub fn stage_file(&self, file: &str) -> Result<bool> {
145        // Let git decide whether the file can be staged. This handles:
146        //   - existing files (additions/modifications)
147        //   - tracked files deleted from the working tree (deletions/moves)
148        //   - files that don't exist and aren't tracked (returns false)
149        // Previous code ran `git ls-files --deleted` per file as a pre-check,
150        // which was O(n²) for many deletes and could fail when path formats
151        // differed between git commands (e.g. C-quoted vs unquoted paths).
152        let (ok, _) = self.git_allow_failure(&["add", "--", file])?;
153        Ok(ok)
154    }
155
156    pub fn has_staged_after_add(&self) -> Result<bool> {
157        self.has_staged_changes()
158    }
159
160    pub fn commit(&self, message: &str) -> Result<()> {
161        let output = Command::new("git")
162            .args(["-C", self.root.to_str().unwrap()])
163            .args(["commit", "-F", "-"])
164            .stdin(std::process::Stdio::piped())
165            .stdout(std::process::Stdio::piped())
166            .stderr(std::process::Stdio::piped())
167            .spawn()
168            .context("failed to spawn git commit")?;
169
170        use std::io::Write;
171        let mut child = output;
172        if let Some(mut stdin) = child.stdin.take() {
173            stdin.write_all(message.as_bytes())?;
174        }
175
176        let out = child.wait_with_output()?;
177        if !out.status.success() {
178            let stderr = String::from_utf8_lossy(&out.stderr);
179            bail!(crate::error::SrAiError::GitCommand(format!(
180                "git commit failed: {}",
181                stderr.trim()
182            )));
183        }
184
185        Ok(())
186    }
187
188    pub fn recent_commits(&self, count: usize) -> Result<String> {
189        self.git(&["--no-pager", "log", "--oneline", &format!("-{count}")])
190    }
191
192    pub fn diff_cached(&self) -> Result<String> {
193        self.git(&["diff", "--cached"])
194    }
195
196    pub fn diff_cached_stat(&self) -> Result<String> {
197        self.git(&["diff", "--cached", "--stat"])
198    }
199
200    pub fn diff_head(&self) -> Result<String> {
201        let (ok, out) = self.git_allow_failure(&["diff", "HEAD"])?;
202        if ok { Ok(out) } else { self.git(&["diff"]) }
203    }
204
205    pub fn status_porcelain(&self) -> Result<String> {
206        self.git(&["status", "--porcelain"])
207    }
208
209    pub fn untracked_files(&self) -> Result<String> {
210        self.git(&["ls-files", "--others", "--exclude-standard"])
211    }
212
213    pub fn show(&self, rev: &str) -> Result<String> {
214        self.git(&["show", rev])
215    }
216
217    pub fn log_range(&self, base: &str, count: Option<usize>) -> Result<String> {
218        let mut args = vec!["--no-pager", "log", "--oneline"];
219        let count_str;
220        if let Some(n) = count {
221            count_str = format!("-{n}");
222            args.push(&count_str);
223        }
224        args.push(base);
225        self.git(&args)
226    }
227
228    pub fn diff_range(&self, base: &str) -> Result<String> {
229        self.git(&["diff", base])
230    }
231
232    pub fn current_branch(&self) -> Result<String> {
233        let out = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
234        Ok(out.trim().to_string())
235    }
236
237    pub fn head_short(&self) -> Result<String> {
238        let out = self.git(&["rev-parse", "--short", "HEAD"])?;
239        Ok(out.trim().to_string())
240    }
241
242    /// Count commits since the last tag. If no tags exist, counts all commits.
243    pub fn commits_since_last_tag(&self) -> Result<usize> {
244        // Try to find the most recent tag
245        let (ok, tag) = self.git_allow_failure(&["describe", "--tags", "--abbrev=0"])?;
246        let tag = tag.trim();
247
248        let out = if ok && !tag.is_empty() {
249            self.git(&["rev-list", &format!("{tag}..HEAD"), "--count"])?
250        } else {
251            self.git(&["rev-list", "HEAD", "--count"])?
252        };
253
254        out.trim()
255            .parse::<usize>()
256            .context("failed to parse commit count")
257    }
258
259    /// Get detailed log of recent commits (SHA, subject, body) oldest first.
260    pub fn log_detailed(&self, count: usize) -> Result<String> {
261        let out = self.git(&[
262            "--no-pager",
263            "log",
264            "--reverse",
265            &format!("-{count}"),
266            "--format=%h %s%n%b%n---",
267        ])?;
268        Ok(out)
269    }
270
271    pub fn file_statuses(&self) -> Result<HashMap<String, char>> {
272        let out = self.git(&["status", "--porcelain"])?;
273        let mut map = HashMap::new();
274        for line in out.lines() {
275            if line.len() < 3 {
276                continue;
277            }
278            let xy = &line.as_bytes()[..2];
279            let path = line[3..].to_string();
280            let (x, y) = (xy[0], xy[1]);
281            let is_rename = matches!((x, y), (b'R', _) | (_, b'R'));
282            if is_rename {
283                if let Some(pos) = path.find(" -> ") {
284                    let old_path = git_unquote(&path[..pos]);
285                    let new_path = git_unquote(&path[pos + 4..]);
286                    map.insert(old_path, 'D');
287                    map.insert(new_path, 'R');
288                } else {
289                    map.insert(git_unquote(&path), 'R');
290                }
291            } else {
292                let status = match (x, y) {
293                    (b'?', b'?') => 'A',
294                    (b'A', _) | (_, b'A') => 'A',
295                    (b'D', _) | (_, b'D') => 'D',
296                    (b'M', _) | (_, b'M') | (b'T', _) | (_, b'T') => 'M',
297                    _ => '~',
298                };
299                map.insert(git_unquote(&path), status);
300            }
301        }
302        Ok(map)
303    }
304
305    /// Create a snapshot of the working tree state into the platform data directory.
306    /// Location: `<data_local_dir>/sr/snapshots/<repo-hash>/`
307    ///   - macOS:   ~/Library/Application Support/sr/snapshots/<hash>/
308    ///   - Linux:   ~/.local/share/sr/snapshots/<hash>/
309    ///   - Windows: %LOCALAPPDATA%/sr/snapshots/<hash>/
310    ///
311    /// The snapshot directly copies every changed/added/deleted file into
312    /// `files/` alongside a `manifest.json` that records each file's status
313    /// and whether it was staged. This avoids git-stash entirely — restore
314    /// is a plain file copy that cannot conflict.
315    ///
316    /// Lives completely outside the repo so the agent cannot touch it.
317    pub fn snapshot_working_tree(&self) -> Result<PathBuf> {
318        let snapshot_dir = snapshot_dir_for(&self.root)
319            .context("failed to resolve snapshot directory (no data directory available)")?;
320        // Start fresh — remove any prior snapshot for this repo
321        if snapshot_dir.exists() {
322            std::fs::remove_dir_all(&snapshot_dir).ok();
323        }
324        std::fs::create_dir_all(&snapshot_dir).context("failed to create snapshot directory")?;
325
326        let files_dir = snapshot_dir.join("files");
327        std::fs::create_dir_all(&files_dir)?;
328
329        // Record which repo this snapshot belongs to
330        std::fs::write(
331            snapshot_dir.join("repo_root"),
332            self.root.to_string_lossy().as_bytes(),
333        )
334        .context("failed to write repo_root")?;
335
336        // Record current HEAD so we can reset if partial commits were made
337        let (has_head, head_ref) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
338        if has_head {
339            std::fs::write(snapshot_dir.join("head_ref"), head_ref.trim())
340                .context("failed to write head_ref")?;
341        }
342
343        // Build manifest: every file that shows up in `git status --porcelain`
344        // gets its content copied and its status recorded.
345        let porcelain = self.git(&["status", "--porcelain"])?;
346        let staged_names = self.git(&["diff", "--cached", "--name-only", "-z"])?;
347        let staged_set: std::collections::HashSet<String> = staged_names
348            .split('\0')
349            .map(|l| l.trim().to_string())
350            .filter(|l| !l.is_empty())
351            .collect();
352
353        #[derive(serde::Serialize, serde::Deserialize)]
354        struct ManifestEntry {
355            path: String,
356            /// X (index) status character from porcelain
357            index_status: char,
358            /// Y (worktree) status character from porcelain
359            worktree_status: char,
360            /// Whether the file was staged at snapshot time
361            staged: bool,
362            /// Whether a file copy exists in the snapshot (false for deletions)
363            has_content: bool,
364        }
365
366        let mut manifest: Vec<ManifestEntry> = Vec::new();
367
368        for line in porcelain.lines() {
369            if line.len() < 3 {
370                continue;
371            }
372            let bytes = line.as_bytes();
373            let x = bytes[0] as char;
374            let y = bytes[1] as char;
375            let raw = line[3..].to_string();
376            // Handle renames: "R  old -> new" — keep only the new path
377            let path = if let Some(pos) = raw.find(" -> ") {
378                git_unquote(&raw[pos + 4..])
379            } else {
380                git_unquote(&raw)
381            };
382
383            let src = self.root.join(&path);
384            let has_content = src.exists() && src.is_file();
385
386            if has_content {
387                let dest = files_dir.join(&path);
388                if let Some(parent) = dest.parent() {
389                    std::fs::create_dir_all(parent).ok();
390                }
391                if let Err(e) = std::fs::copy(&src, &dest) {
392                    eprintln!("warning: failed to snapshot {path}: {e}");
393                }
394            }
395
396            manifest.push(ManifestEntry {
397                staged: staged_set.contains(path.as_str()),
398                path,
399                index_status: x,
400                worktree_status: y,
401                has_content,
402            });
403        }
404
405        let manifest_json =
406            serde_json::to_string_pretty(&manifest).context("failed to serialize manifest")?;
407        std::fs::write(snapshot_dir.join("manifest.json"), manifest_json)
408            .context("failed to write manifest.json")?;
409
410        // Mark snapshot as valid
411        let now = std::time::SystemTime::now()
412            .duration_since(std::time::UNIX_EPOCH)
413            .unwrap_or_default()
414            .as_secs();
415        std::fs::write(snapshot_dir.join("timestamp"), now.to_string())
416            .context("failed to write timestamp")?;
417
418        Ok(snapshot_dir)
419    }
420
421    /// Restore working tree from the latest snapshot.
422    ///
423    /// 1. Reset HEAD to the original commit (undoes any partial commits)
424    /// 2. Clean the index
425    /// 3. Copy every snapshotted file back from `files/`
426    /// 4. Delete files that were deleted at snapshot time
427    /// 5. Re-stage files that were staged at snapshot time
428    ///
429    /// This is a plain file copy — no git-stash, no merge conflicts.
430    pub fn restore_snapshot(&self) -> Result<()> {
431        let snapshot_dir = self.snapshot_dir()?;
432        if !snapshot_dir.join("timestamp").exists() {
433            bail!("no valid snapshot found");
434        }
435
436        let files_dir = snapshot_dir.join("files");
437
438        // Step 1: Reset HEAD to pre-operation state
439        let head_ref_path = snapshot_dir.join("head_ref");
440        if head_ref_path.exists() {
441            let original_head = std::fs::read_to_string(&head_ref_path)?;
442            let original_head = original_head.trim();
443            if !original_head.is_empty() {
444                let _ = self.git_allow_failure(&["reset", "--soft", original_head]);
445            }
446        }
447
448        // Step 2: Clean the index
449        self.reset_head()?;
450
451        // Step 3-5: Restore files from manifest
452        let manifest_path = snapshot_dir.join("manifest.json");
453        if !manifest_path.exists() {
454            bail!("snapshot manifest.json missing — cannot restore");
455        }
456
457        #[derive(serde::Deserialize)]
458        struct ManifestEntry {
459            path: String,
460            index_status: char,
461            worktree_status: char,
462            staged: bool,
463            has_content: bool,
464        }
465
466        let manifest_data = std::fs::read_to_string(&manifest_path)?;
467        let manifest: Vec<ManifestEntry> =
468            serde_json::from_str(&manifest_data).context("failed to parse snapshot manifest")?;
469
470        let mut restored = 0usize;
471        let mut failed = 0usize;
472
473        for entry in &manifest {
474            let dest = self.root.join(&entry.path);
475
476            if entry.has_content {
477                // Restore file content from snapshot copy
478                let src = files_dir.join(&entry.path);
479                if src.exists() {
480                    if let Some(parent) = dest.parent() {
481                        std::fs::create_dir_all(parent).ok();
482                    }
483                    match std::fs::copy(&src, &dest) {
484                        Ok(_) => restored += 1,
485                        Err(e) => {
486                            eprintln!("warning: failed to restore {}: {e}", entry.path);
487                            failed += 1;
488                        }
489                    }
490                } else {
491                    eprintln!("warning: snapshot missing content for {}", entry.path);
492                    failed += 1;
493                }
494            } else if entry.index_status == 'D' || entry.worktree_status == 'D' {
495                // File was deleted at snapshot time — ensure it stays deleted
496                if dest.exists() {
497                    std::fs::remove_file(&dest).ok();
498                }
499            }
500
501            // Re-stage if it was staged at snapshot time
502            if entry.staged {
503                let _ = self.git_allow_failure(&["add", "--", &entry.path]);
504            }
505        }
506
507        if failed > 0 {
508            eprintln!("sr: restored {restored} files, {failed} failed");
509        }
510
511        Ok(())
512    }
513
514    /// Remove the snapshot after a successful operation.
515    pub fn clear_snapshot(&self) {
516        if let Ok(dir) = self.snapshot_dir() {
517            let _ = std::fs::remove_dir_all(&dir);
518        }
519    }
520
521    /// Returns the snapshot directory path for this repo.
522    pub fn snapshot_dir(&self) -> Result<PathBuf> {
523        snapshot_dir_for(&self.root)
524            .context("failed to resolve snapshot directory (no data directory available)")
525    }
526
527    /// Check if a valid snapshot exists.
528    pub fn has_snapshot(&self) -> bool {
529        self.snapshot_dir()
530            .map(|d| d.join("timestamp").exists())
531            .unwrap_or(false)
532    }
533}
534
535/// Resolve the snapshot directory for a repo root.
536/// `<data_local_dir>/sr/snapshots/<repo-hash>/`
537fn snapshot_dir_for(repo_root: &std::path::Path) -> Option<PathBuf> {
538    let base = dirs::data_local_dir()?;
539    let repo_id =
540        &crate::cache::fingerprint::sha256_hex(repo_root.to_string_lossy().as_bytes())[..16];
541    Some(base.join("sr").join("snapshots").join(repo_id))
542}
543
544/// Guard that ensures the snapshot is cleaned up on success
545/// and restored on failure (drop without explicit success).
546pub struct SnapshotGuard<'a> {
547    repo: &'a GitRepo,
548    succeeded: bool,
549}
550
551impl<'a> SnapshotGuard<'a> {
552    /// Create a snapshot and return the guard.
553    pub fn new(repo: &'a GitRepo) -> Result<Self> {
554        repo.snapshot_working_tree()?;
555        Ok(Self {
556            repo,
557            succeeded: false,
558        })
559    }
560
561    /// Mark the operation as successful — snapshot will be cleared on drop.
562    pub fn success(mut self) {
563        self.succeeded = true;
564        self.repo.clear_snapshot();
565    }
566}
567
568impl Drop for SnapshotGuard<'_> {
569    fn drop(&mut self) {
570        if !self.succeeded && self.repo.has_snapshot() {
571            eprintln!("sr: operation failed, restoring working tree from snapshot...");
572            if let Err(e) = self.repo.restore_snapshot() {
573                eprintln!("sr: warning: snapshot restore failed: {e}");
574                if let Ok(dir) = self.repo.snapshot_dir() {
575                    eprintln!(
576                        "sr: snapshot preserved at {} for manual recovery",
577                        dir.display()
578                    );
579                }
580            } else {
581                self.repo.clear_snapshot();
582            }
583        }
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use std::fs;
591
592    /// Create a temporary git repo with an initial commit and return a GitRepo.
593    fn temp_repo() -> (tempfile::TempDir, GitRepo) {
594        let dir = tempfile::tempdir().unwrap();
595        let root = dir.path().to_path_buf();
596
597        let git = |args: &[&str]| {
598            Command::new("git")
599                .args(["-C", root.to_str().unwrap()])
600                .args(args)
601                .output()
602                .unwrap()
603        };
604
605        git(&["init"]);
606        git(&["config", "user.email", "test@test.com"]);
607        git(&["config", "user.name", "Test"]);
608        // Initial commit so HEAD exists
609        fs::write(root.join("init.txt"), "init").unwrap();
610        git(&["add", "init.txt"]);
611        git(&["commit", "-m", "initial"]);
612
613        let repo = GitRepo { root };
614        (dir, repo)
615    }
616
617    #[test]
618    fn snapshot_creates_manifest_with_staged_files() {
619        let (_dir, repo) = temp_repo();
620
621        // Create and stage a new file
622        fs::write(repo.root.join("new.go"), "package main").unwrap();
623        repo.git(&["add", "new.go"]).unwrap();
624
625        let snap_dir = repo.snapshot_working_tree().unwrap();
626
627        // Manifest should exist
628        let manifest_path = snap_dir.join("manifest.json");
629        assert!(manifest_path.exists(), "manifest.json should exist");
630
631        let data = fs::read_to_string(&manifest_path).unwrap();
632        assert!(data.contains("new.go"), "manifest should list new.go");
633        assert!(
634            data.contains("\"staged\": true"),
635            "new.go should be marked staged"
636        );
637
638        // File copy should exist
639        assert!(
640            snap_dir.join("files/new.go").exists(),
641            "file content should be copied"
642        );
643        assert_eq!(
644            fs::read_to_string(snap_dir.join("files/new.go")).unwrap(),
645            "package main"
646        );
647
648        // HEAD ref should be recorded
649        assert!(snap_dir.join("head_ref").exists());
650
651        repo.clear_snapshot();
652    }
653
654    #[test]
655    fn snapshot_restore_recovers_staged_new_files() {
656        let (_dir, repo) = temp_repo();
657
658        // Stage two new files
659        fs::write(repo.root.join("a.go"), "package a").unwrap();
660        fs::write(repo.root.join("b.go"), "package b").unwrap();
661        repo.git(&["add", "a.go", "b.go"]).unwrap();
662
663        repo.snapshot_working_tree().unwrap();
664
665        // Simulate what execute_plan does: reset head, stage partially, commit
666        repo.reset_head().unwrap();
667        repo.git(&["add", "a.go"]).unwrap();
668        repo.git(&["commit", "-m", "partial"]).unwrap();
669
670        // Now restore — should undo the partial commit and recover both files staged
671        repo.restore_snapshot().unwrap();
672
673        // Both files should exist
674        assert!(repo.root.join("a.go").exists());
675        assert!(repo.root.join("b.go").exists());
676        assert_eq!(
677            fs::read_to_string(repo.root.join("a.go")).unwrap(),
678            "package a"
679        );
680        assert_eq!(
681            fs::read_to_string(repo.root.join("b.go")).unwrap(),
682            "package b"
683        );
684
685        // Both should be staged
686        let staged = repo.git(&["diff", "--cached", "--name-only"]).unwrap();
687        assert!(staged.contains("a.go"), "a.go should be re-staged");
688        assert!(staged.contains("b.go"), "b.go should be re-staged");
689
690        // The partial commit should be gone
691        let log = repo.git(&["log", "--oneline"]).unwrap();
692        assert!(
693            !log.contains("partial"),
694            "partial commit should be undone by HEAD reset"
695        );
696
697        repo.clear_snapshot();
698    }
699
700    #[test]
701    fn snapshot_restore_with_dirty_index_does_not_conflict() {
702        let (_dir, repo) = temp_repo();
703
704        // Stage a new file
705        fs::write(repo.root.join("file.rs"), "fn main() {}").unwrap();
706        repo.git(&["add", "file.rs"]).unwrap();
707
708        repo.snapshot_working_tree().unwrap();
709
710        // Simulate partial staging left by a failed execute_plan
711        repo.reset_head().unwrap();
712        repo.git(&["add", "file.rs"]).unwrap();
713        // Don't commit — index is dirty with the same file
714
715        // Restore should NOT fail (this was the original bug)
716        let result = repo.restore_snapshot();
717        assert!(
718            result.is_ok(),
719            "restore should succeed with dirty index: {result:?}"
720        );
721
722        assert_eq!(
723            fs::read_to_string(repo.root.join("file.rs")).unwrap(),
724            "fn main() {}"
725        );
726
727        repo.clear_snapshot();
728    }
729
730    #[test]
731    fn snapshot_handles_modified_files() {
732        let (_dir, repo) = temp_repo();
733
734        // Modify an existing tracked file
735        fs::write(repo.root.join("init.txt"), "modified content").unwrap();
736        repo.git(&["add", "init.txt"]).unwrap();
737
738        repo.snapshot_working_tree().unwrap();
739
740        // Simulate: reset and make a different change
741        repo.reset_head().unwrap();
742        fs::write(repo.root.join("init.txt"), "wrong content").unwrap();
743
744        // Restore should bring back the original modified content
745        repo.restore_snapshot().unwrap();
746
747        assert_eq!(
748            fs::read_to_string(repo.root.join("init.txt")).unwrap(),
749            "modified content"
750        );
751
752        repo.clear_snapshot();
753    }
754
755    #[test]
756    fn snapshot_guard_restores_on_drop() {
757        let (_dir, repo) = temp_repo();
758
759        fs::write(repo.root.join("guarded.txt"), "important").unwrap();
760        repo.git(&["add", "guarded.txt"]).unwrap();
761
762        {
763            let _guard = SnapshotGuard::new(&repo).unwrap();
764            // Simulate failure: reset and delete the file
765            repo.reset_head().unwrap();
766            fs::remove_file(repo.root.join("guarded.txt")).ok();
767            // Guard drops here without calling success()
768        }
769
770        // File should be restored
771        assert!(repo.root.join("guarded.txt").exists());
772        assert_eq!(
773            fs::read_to_string(repo.root.join("guarded.txt")).unwrap(),
774            "important"
775        );
776    }
777
778    #[test]
779    fn snapshot_guard_clears_on_success() {
780        let (_dir, repo) = temp_repo();
781
782        fs::write(repo.root.join("ok.txt"), "data").unwrap();
783        repo.git(&["add", "ok.txt"]).unwrap();
784
785        let guard = SnapshotGuard::new(&repo).unwrap();
786        assert!(repo.has_snapshot());
787        guard.success();
788
789        // Snapshot should be cleared
790        assert!(!repo.has_snapshot());
791    }
792
793    #[test]
794    fn file_statuses_includes_both_sides_of_rename() {
795        let (_dir, repo) = temp_repo();
796
797        // Create and commit a file
798        fs::write(repo.root.join("old_name.txt"), "content").unwrap();
799        repo.git(&["add", "old_name.txt"]).unwrap();
800        repo.git(&["commit", "-m", "add old_name"]).unwrap();
801
802        // Rename it via git mv
803        repo.git(&["mv", "old_name.txt", "new_name.txt"]).unwrap();
804
805        let statuses = repo.file_statuses().unwrap();
806
807        assert_eq!(
808            statuses.get("old_name.txt").copied(),
809            Some('D'),
810            "old path should appear as deleted"
811        );
812        assert_eq!(
813            statuses.get("new_name.txt").copied(),
814            Some('R'),
815            "new path should appear as renamed"
816        );
817    }
818
819    /// Simulate the execute_plan flow: many files with moves, deletes, and
820    /// modifications. After reset_head(), every path from file_statuses()
821    /// must be stageable via stage_file(). This is the scenario that breaks
822    /// when there are 100+ changes with moves.
823    #[test]
824    fn stage_file_handles_many_moves_and_deletes_after_reset() {
825        let (_dir, repo) = temp_repo();
826
827        // Create 30 files and commit them
828        for i in 0..30 {
829            fs::write(
830                repo.root.join(format!("file_{i}.txt")),
831                format!("content {i}"),
832            )
833            .unwrap();
834        }
835        repo.git(&["add", "."]).unwrap();
836        repo.git(&["commit", "-m", "add files"]).unwrap();
837
838        // Move files 0..10 into a subdirectory (simulates directory rename)
839        fs::create_dir_all(repo.root.join("moved")).unwrap();
840        for i in 0..10 {
841            repo.git(&[
842                "mv",
843                &format!("file_{i}.txt"),
844                &format!("moved/file_{i}.txt"),
845            ])
846            .unwrap();
847        }
848
849        // Delete files 10..20
850        for i in 10..20 {
851            repo.git(&["rm", &format!("file_{i}.txt")]).unwrap();
852        }
853
854        // Modify files 20..30
855        for i in 20..30 {
856            fs::write(
857                repo.root.join(format!("file_{i}.txt")),
858                format!("modified {i}"),
859            )
860            .unwrap();
861            repo.git(&["add", &format!("file_{i}.txt")]).unwrap();
862        }
863
864        // Add some new files too
865        for i in 30..35 {
866            fs::write(repo.root.join(format!("new_{i}.txt")), format!("new {i}")).unwrap();
867            repo.git(&["add", &format!("new_{i}.txt")]).unwrap();
868        }
869
870        // Capture statuses before reset (this is what the AI sees)
871        let statuses = repo.file_statuses().unwrap();
872        assert!(
873            statuses.len() >= 30,
874            "should have many file statuses, got {}",
875            statuses.len()
876        );
877
878        // Reset head — exactly what execute_plan does
879        repo.reset_head().unwrap();
880
881        // Now try to stage every file from statuses — this is what execute_plan does
882        let mut failed = Vec::new();
883        for (file, status) in &statuses {
884            if file == "init.txt" {
885                continue;
886            }
887            let ok = repo.stage_file(file).unwrap();
888            if !ok {
889                failed.push((file.clone(), *status));
890            }
891        }
892
893        assert!(
894            failed.is_empty(),
895            "stage_file failed for {} files: {:?}",
896            failed.len(),
897            failed
898        );
899    }
900
901    /// Test that stage_file works when files are moved MANUALLY (not git mv)
902    /// and then staged with git add. This is the common case for directory
903    /// renames where users just mv the directory and git add everything.
904    #[test]
905    fn stage_file_handles_manual_moves_after_reset() {
906        let (_dir, repo) = temp_repo();
907
908        // Create files in a directory and commit
909        fs::create_dir_all(repo.root.join("old_dir")).unwrap();
910        for i in 0..10 {
911            fs::write(
912                repo.root.join(format!("old_dir/file_{i}.txt")),
913                format!("content {i}"),
914            )
915            .unwrap();
916        }
917        repo.git(&["add", "."]).unwrap();
918        repo.git(&["commit", "-m", "add directory"]).unwrap();
919
920        // Manually move the directory (simulates user doing: mv old_dir new_dir)
921        fs::rename(repo.root.join("old_dir"), repo.root.join("new_dir")).unwrap();
922
923        // Stage everything (simulates: git add -A)
924        repo.git(&["add", "-A"]).unwrap();
925
926        // Capture statuses
927        let statuses = repo.file_statuses().unwrap();
928
929        // Reset head — like execute_plan does
930        repo.reset_head().unwrap();
931
932        // Try to stage every file
933        let mut failed = Vec::new();
934        for (file, status) in &statuses {
935            if file == "init.txt" {
936                continue;
937            }
938            let ok = repo.stage_file(file).unwrap();
939            if !ok {
940                failed.push((file.clone(), *status));
941            }
942        }
943
944        assert!(
945            failed.is_empty(),
946            "stage_file failed for {} files after manual move: {:?}",
947            failed.len(),
948            failed
949        );
950    }
951
952    /// Test that stage_file works when new (uncommitted) files are involved
953    /// alongside moves and deletes. New files that were staged but never
954    /// committed are tricky because after reset_head() they drop out of
955    /// the index entirely.
956    #[test]
957    fn stage_file_handles_new_files_mixed_with_moves() {
958        let (_dir, repo) = temp_repo();
959
960        // Create and commit existing files
961        for i in 0..5 {
962            fs::write(
963                repo.root.join(format!("existing_{i}.txt")),
964                format!("existing {i}"),
965            )
966            .unwrap();
967        }
968        repo.git(&["add", "."]).unwrap();
969        repo.git(&["commit", "-m", "add existing files"]).unwrap();
970
971        // Move some existing files
972        fs::create_dir_all(repo.root.join("moved")).unwrap();
973        for i in 0..3 {
974            repo.git(&[
975                "mv",
976                &format!("existing_{i}.txt"),
977                &format!("moved/existing_{i}.txt"),
978            ])
979            .unwrap();
980        }
981
982        // Delete some existing files
983        repo.git(&["rm", "existing_3.txt"]).unwrap();
984
985        // Add brand new files (never committed)
986        for i in 0..5 {
987            fs::write(
988                repo.root.join(format!("brand_new_{i}.txt")),
989                format!("new {i}"),
990            )
991            .unwrap();
992        }
993        repo.git(&["add", "."]).unwrap();
994
995        // Capture statuses — includes both committed moves AND new files
996        let statuses = repo.file_statuses().unwrap();
997
998        // Reset head
999        repo.reset_head().unwrap();
1000
1001        // Stage each file — new files should still be on disk and stageable
1002        let mut failed = Vec::new();
1003        for (file, status) in &statuses {
1004            if file == "init.txt" {
1005                continue;
1006            }
1007            let ok = repo.stage_file(file).unwrap();
1008            if !ok {
1009                failed.push((file.clone(), *status));
1010            }
1011        }
1012
1013        assert!(
1014            failed.is_empty(),
1015            "stage_file failed for {} files: {:?}",
1016            failed.len(),
1017            failed
1018        );
1019    }
1020
1021    /// Regression: git status --porcelain C-quotes paths that contain
1022    /// spaces or non-ASCII characters.  file_statuses() must unquote
1023    /// them so that stage_file receives real filesystem paths, not
1024    /// quoted strings that git add cannot resolve.
1025    #[test]
1026    fn stage_file_handles_quoted_paths_from_moves() {
1027        let (_dir, repo) = temp_repo();
1028
1029        // Create and commit a file with spaces in the name
1030        fs::write(repo.root.join("old name.txt"), "content").unwrap();
1031        repo.git(&["add", "."]).unwrap();
1032        repo.git(&["commit", "-m", "add file with spaces"]).unwrap();
1033
1034        // Move it (git mv)
1035        repo.git(&["mv", "old name.txt", "new name.txt"]).unwrap();
1036
1037        // file_statuses must return unquoted paths
1038        let statuses = repo.file_statuses().unwrap();
1039
1040        // The paths should NOT have C-quotes
1041        assert!(
1042            statuses.contains_key("old name.txt"),
1043            "old path should be unquoted; got keys: {:?}",
1044            statuses.keys().collect::<Vec<_>>()
1045        );
1046        assert!(
1047            statuses.contains_key("new name.txt"),
1048            "new path should be unquoted; got keys: {:?}",
1049            statuses.keys().collect::<Vec<_>>()
1050        );
1051
1052        // After reset, stage_file must succeed for both sides
1053        repo.reset_head().unwrap();
1054
1055        let old_ok = repo.stage_file("old name.txt").unwrap();
1056        assert!(old_ok, "stage_file should succeed for old (deleted) path");
1057
1058        let new_ok = repo.stage_file("new name.txt").unwrap();
1059        assert!(new_ok, "stage_file should succeed for new (added) path");
1060    }
1061
1062    /// Regression: ensure file_statuses unquotes C-style paths for
1063    /// non-rename entries too (modified, deleted, added files with spaces).
1064    #[test]
1065    fn file_statuses_unquotes_paths_with_special_chars() {
1066        let (_dir, repo) = temp_repo();
1067
1068        // Create files with spaces
1069        fs::write(repo.root.join("my file.txt"), "content").unwrap();
1070        fs::write(repo.root.join("to delete.txt"), "delete me").unwrap();
1071        repo.git(&["add", "."]).unwrap();
1072        repo.git(&["commit", "-m", "add spaced files"]).unwrap();
1073
1074        // Modify one, delete another, add a new one with spaces
1075        fs::write(repo.root.join("my file.txt"), "modified").unwrap();
1076        repo.git(&["rm", "to delete.txt"]).unwrap();
1077        fs::write(repo.root.join("brand new file.txt"), "new").unwrap();
1078        repo.git(&["add", "."]).unwrap();
1079
1080        let statuses = repo.file_statuses().unwrap();
1081
1082        // All paths should be unquoted
1083        assert!(
1084            statuses.contains_key("my file.txt"),
1085            "modified file should be unquoted; keys: {:?}",
1086            statuses.keys().collect::<Vec<_>>()
1087        );
1088        assert!(
1089            statuses.contains_key("to delete.txt"),
1090            "deleted file should be unquoted; keys: {:?}",
1091            statuses.keys().collect::<Vec<_>>()
1092        );
1093        assert!(
1094            statuses.contains_key("brand new file.txt"),
1095            "new file should be unquoted; keys: {:?}",
1096            statuses.keys().collect::<Vec<_>>()
1097        );
1098    }
1099
1100    /// Test that stage_file works for moved files split across multiple
1101    /// commits (simulating execute_plan with multiple commits where moves
1102    /// are split: new path in one commit, old path deletion in another).
1103    #[test]
1104    fn stage_file_works_across_sequential_commits_with_moves() {
1105        let (_dir, repo) = temp_repo();
1106
1107        // Create and commit files
1108        for i in 0..10 {
1109            fs::write(
1110                repo.root.join(format!("src_{i}.txt")),
1111                format!("content {i}"),
1112            )
1113            .unwrap();
1114        }
1115        repo.git(&["add", "."]).unwrap();
1116        repo.git(&["commit", "-m", "add source files"]).unwrap();
1117
1118        // Move all files to a new directory
1119        fs::create_dir_all(repo.root.join("dst")).unwrap();
1120        for i in 0..10 {
1121            repo.git(&["mv", &format!("src_{i}.txt"), &format!("dst/src_{i}.txt")])
1122                .unwrap();
1123        }
1124
1125        let statuses = repo.file_statuses().unwrap();
1126        repo.reset_head().unwrap();
1127
1128        // Commit 1: stage the NEW paths (additions)
1129        for i in 0..10 {
1130            let file = format!("dst/src_{i}.txt");
1131            let ok = repo.stage_file(&file).unwrap();
1132            assert!(ok, "should stage new path {file}");
1133        }
1134        repo.commit("feat: add new paths").unwrap();
1135
1136        // Commit 2: stage the OLD paths (deletions) — these must still work
1137        // even though HEAD has changed after commit 1
1138        let mut failed = Vec::new();
1139        for i in 0..10 {
1140            let file = format!("src_{i}.txt");
1141            if let Some(&status) = statuses.get(&file) {
1142                let ok = repo.stage_file(&file).unwrap();
1143                if !ok {
1144                    failed.push((file, status));
1145                }
1146            }
1147        }
1148
1149        assert!(
1150            failed.is_empty(),
1151            "stage_file failed for old paths after prior commit: {:?}",
1152            failed
1153        );
1154    }
1155}