Skip to main content

sr_ai/git/
mod.rs

1use anyhow::{Context, Result, bail};
2use std::path::PathBuf;
3use std::process::Command;
4
5pub struct GitRepo {
6    root: PathBuf,
7}
8
9#[allow(dead_code)]
10impl GitRepo {
11    pub fn discover() -> Result<Self> {
12        let output = Command::new("git")
13            .args(["rev-parse", "--show-toplevel"])
14            .output()
15            .context("failed to run git")?;
16
17        if !output.status.success() {
18            bail!(crate::error::SrAiError::NotAGitRepo);
19        }
20
21        let root = String::from_utf8(output.stdout)
22            .context("invalid utf-8 from git")?
23            .trim()
24            .into();
25
26        Ok(Self { root })
27    }
28
29    pub fn root(&self) -> &PathBuf {
30        &self.root
31    }
32
33    fn git(&self, args: &[&str]) -> Result<String> {
34        let output = Command::new("git")
35            .args(["-C", self.root.to_str().unwrap()])
36            .args(args)
37            .output()
38            .with_context(|| format!("failed to run git {}", args.join(" ")))?;
39
40        if !output.status.success() {
41            let stderr = String::from_utf8_lossy(&output.stderr);
42            bail!(crate::error::SrAiError::GitCommand(format!(
43                "git {} failed: {}",
44                args.join(" "),
45                stderr.trim()
46            )));
47        }
48
49        Ok(String::from_utf8_lossy(&output.stdout).to_string())
50    }
51
52    fn git_allow_failure(&self, args: &[&str]) -> Result<(bool, String)> {
53        let output = Command::new("git")
54            .args(["-C", self.root.to_str().unwrap()])
55            .args(args)
56            .output()
57            .with_context(|| format!("failed to run git {}", args.join(" ")))?;
58
59        Ok((
60            output.status.success(),
61            String::from_utf8_lossy(&output.stdout).to_string(),
62        ))
63    }
64
65    pub fn has_staged_changes(&self) -> Result<bool> {
66        let out = self.git(&["diff", "--cached", "--name-only"])?;
67        Ok(!out.trim().is_empty())
68    }
69
70    pub fn has_any_changes(&self) -> Result<bool> {
71        let out = self.git(&["status", "--porcelain"])?;
72        Ok(!out.trim().is_empty())
73    }
74
75    pub fn has_head(&self) -> Result<bool> {
76        let (ok, _) = self.git_allow_failure(&["rev-parse", "HEAD"])?;
77        Ok(ok)
78    }
79
80    pub fn reset_head(&self) -> Result<()> {
81        if self.has_head()? {
82            self.git(&["reset", "HEAD", "--quiet"])?;
83        } else {
84            // Fresh repo with no commits — unstage via rm --cached
85            let _ = self.git_allow_failure(&["rm", "--cached", "-r", ".", "--quiet"]);
86        }
87        Ok(())
88    }
89
90    pub fn stage_file(&self, file: &str) -> Result<bool> {
91        let full_path = self.root.join(file);
92        let exists = full_path.exists();
93
94        if !exists {
95            // Check if it's a deleted file
96            let out = self.git(&["ls-files", "--deleted"])?;
97            let is_deleted = out.lines().any(|l| l.trim() == file);
98            if !is_deleted {
99                return Ok(false);
100            }
101        }
102
103        let (ok, _) = self.git_allow_failure(&["add", "--", file])?;
104        Ok(ok)
105    }
106
107    pub fn has_staged_after_add(&self) -> Result<bool> {
108        self.has_staged_changes()
109    }
110
111    pub fn commit(&self, message: &str) -> Result<()> {
112        let output = Command::new("git")
113            .args(["-C", self.root.to_str().unwrap()])
114            .args(["commit", "-F", "-"])
115            .stdin(std::process::Stdio::piped())
116            .stdout(std::process::Stdio::piped())
117            .stderr(std::process::Stdio::piped())
118            .spawn()
119            .context("failed to spawn git commit")?;
120
121        use std::io::Write;
122        let mut child = output;
123        if let Some(mut stdin) = child.stdin.take() {
124            stdin.write_all(message.as_bytes())?;
125        }
126
127        let out = child.wait_with_output()?;
128        if !out.status.success() {
129            let stderr = String::from_utf8_lossy(&out.stderr);
130            bail!(crate::error::SrAiError::GitCommand(format!(
131                "git commit failed: {}",
132                stderr.trim()
133            )));
134        }
135
136        Ok(())
137    }
138
139    pub fn recent_commits(&self, count: usize) -> Result<String> {
140        self.git(&["--no-pager", "log", "--oneline", &format!("-{count}")])
141    }
142
143    pub fn diff_cached(&self) -> Result<String> {
144        self.git(&["diff", "--cached"])
145    }
146
147    pub fn diff_cached_stat(&self) -> Result<String> {
148        self.git(&["diff", "--cached", "--stat"])
149    }
150
151    pub fn diff_head(&self) -> Result<String> {
152        let (ok, out) = self.git_allow_failure(&["diff", "HEAD"])?;
153        if ok { Ok(out) } else { self.git(&["diff"]) }
154    }
155
156    pub fn status_porcelain(&self) -> Result<String> {
157        self.git(&["status", "--porcelain"])
158    }
159
160    pub fn untracked_files(&self) -> Result<String> {
161        self.git(&["ls-files", "--others", "--exclude-standard"])
162    }
163
164    pub fn show(&self, rev: &str) -> Result<String> {
165        self.git(&["show", rev])
166    }
167
168    pub fn log_range(&self, base: &str, count: Option<usize>) -> Result<String> {
169        let mut args = vec!["--no-pager", "log", "--oneline"];
170        let count_str;
171        if let Some(n) = count {
172            count_str = format!("-{n}");
173            args.push(&count_str);
174        }
175        args.push(base);
176        self.git(&args)
177    }
178
179    pub fn diff_range(&self, base: &str) -> Result<String> {
180        self.git(&["diff", base])
181    }
182
183    pub fn current_branch(&self) -> Result<String> {
184        let out = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
185        Ok(out.trim().to_string())
186    }
187}