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