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 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 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}