Skip to main content

sr_core/git/
mod.rs

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