Skip to main content

sr_ai/git/
mod.rs

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