1use crate::error::{Result, SiError};
2use std::process::Command;
3
4pub struct Git;
5
6fn run_git(args: &[&str]) -> Result<std::process::Output> {
7 Command::new("git")
8 .args(args)
9 .output()
10 .map_err(|e| SiError::Git(format!("failed to execute `git {}`: {e}", args.join(" "))))
11}
12
13fn ensure_success(args: &[&str], output: &std::process::Output) -> Result<()> {
14 if output.status.success() {
15 Ok(())
16 } else {
17 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
18 Err(SiError::Git(format!(
19 "`git {}` failed: {stderr}",
20 args.join(" ")
21 )))
22 }
23}
24
25impl Git {
26 pub fn is_inside_work_tree() -> bool {
27 run_git(&["rev-parse", "--is-inside-work-tree"])
28 .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true")
29 .unwrap_or(false)
30 }
31
32 pub fn ensure_repo() -> Result<()> {
33 if Self::is_inside_work_tree() {
34 Ok(())
35 } else {
36 Err(SiError::NotARepo)
37 }
38 }
39
40 pub fn add_files(files: &[String]) -> Result<()> {
42 if files.is_empty() {
43 return Self::add_all_changes();
44 }
45 let mut args: Vec<&str> = vec!["add", "--"];
46 args.extend(files.iter().map(|s| s.as_str()));
47 let output = run_git(&args)?;
48 ensure_success(&args, &output)
49 }
50
51 pub fn add_all_changes() -> Result<()> {
52 let output = run_git(&["add", "."])?;
53 ensure_success(&["add", "."], &output)?;
54 println!("📁 Staged all modified files");
55 Ok(())
56 }
57
58 pub fn has_unstaged_changes() -> Result<bool> {
62 let output = run_git(&["status", "--porcelain"])?;
63 ensure_success(&["status", "--porcelain"], &output)?;
64 let text = String::from_utf8_lossy(&output.stdout);
65 Ok(text.lines().any(|line| {
70 let bytes = line.as_bytes();
71 bytes.len() >= 2 && (bytes[1] != b' ' || line.starts_with("??"))
72 }))
73 }
74
75 pub fn has_staged_changes() -> Result<bool> {
76 let output = run_git(&["diff", "--cached", "--quiet"])?;
77 Ok(!output.status.success())
78 }
79
80 pub fn get_staged_stat() -> Result<String> {
81 let output = run_git(&["diff", "--cached", "--stat"])?;
82 ensure_success(&["diff", "--cached", "--stat"], &output)?;
83 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
84 }
85
86 pub fn get_staged_diff_raw() -> Result<String> {
88 let output = run_git(&["diff", "--cached", "--no-color"])?;
89 ensure_success(&["diff", "--cached", "--no-color"], &output)?;
90 let diff = String::from_utf8(output.stdout)
91 .map_err(|e| SiError::Git(format!("invalid UTF-8 in git diff output: {e}")))?;
92 if diff.trim().is_empty() {
93 return Err(SiError::NoStagedChanges);
94 }
95 Ok(diff)
96 }
97
98 pub fn get_staged_diff() -> Result<String> {
99 let raw = Self::get_staged_diff_raw()?;
100 Ok(format_diff_for_llm(&raw, DEFAULT_DIFF_LIMIT))
101 }
102
103 pub fn commit(subject: &str, body: Option<&str>) -> Result<()> {
104 let mut cmd = Command::new("git");
105 cmd.arg("commit").arg("-m").arg(subject);
106 if let Some(body_text) = body {
107 if !body_text.trim().is_empty() {
108 cmd.arg("-m").arg(body_text);
109 }
110 }
111 let output = cmd
112 .output()
113 .map_err(|e| SiError::Git(format!("failed to execute `git commit`: {e}")))?;
114 if !output.status.success() {
115 let stderr = String::from_utf8_lossy(&output.stderr);
116 return Err(SiError::Git(format!("git commit failed: {stderr}")));
117 }
118 println!("✓ Commit created successfully");
119 Ok(())
120 }
121
122 pub fn push(branch: Option<&str>) -> Result<()> {
123 let mut cmd = Command::new("git");
124 cmd.arg("push");
125 if let Some(branch_name) = branch {
126 cmd.arg("origin").arg(branch_name);
127 }
128 let output = cmd
129 .output()
130 .map_err(|e| SiError::Git(format!("failed to execute `git push`: {e}")))?;
131 if !output.status.success() {
132 let stderr = String::from_utf8_lossy(&output.stderr);
133 return Err(SiError::Git(format!("git push failed: {stderr}")));
134 }
135 println!("✓ Changes pushed to remote successfully");
136 Ok(())
137 }
138
139 pub fn get_current_branch() -> Result<String> {
140 let output = run_git(&["branch", "--show-current"])?;
141 ensure_success(&["branch", "--show-current"], &output)?;
142 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
143 if branch.is_empty() {
144 return Err(SiError::Git(
145 "could not determine current branch (detached HEAD?). Specify one with --branch"
146 .to_string(),
147 ));
148 }
149 Ok(branch)
150 }
151}
152
153pub const DEFAULT_DIFF_LIMIT: usize = 12_000;
154
155pub fn format_diff_for_llm(raw_diff: &str, limit: usize) -> String {
159 let mut formatted = String::new();
160 let mut current_file = String::new();
161 let mut changes: Vec<String> = Vec::new();
162
163 let flush = |formatted: &mut String, file: &str, changes: &Vec<String>| {
164 if !file.is_empty() && !changes.is_empty() {
165 formatted.push_str(&format!("File: {file}\n"));
166 formatted.push_str(&changes.join("\n"));
167 formatted.push_str("\n\n");
168 }
169 };
170
171 for line in raw_diff.lines() {
172 if let Some(rest) = line.strip_prefix("diff --git ") {
173 flush(&mut formatted, ¤t_file, &changes);
174 current_file = rest
176 .split_whitespace()
177 .nth(1)
178 .unwrap_or("unknown")
179 .trim_start_matches("b/")
180 .to_string();
181 changes.clear();
182 } else if line.starts_with("+++") || line.starts_with("---") {
183 continue;
184 } else if line.starts_with("@@") {
185 changes.push(format!("Changes at {line}"));
186 } else if let Some(content) = line.strip_prefix('+') {
187 changes.push(format!(" Added: {content}"));
188 } else if let Some(content) = line.strip_prefix('-') {
189 changes.push(format!(" Removed: {content}"));
190 } else if let Some(content) = line.strip_prefix(' ') {
191 if !content.trim().is_empty() && changes.len() < 40 {
192 changes.push(format!(" Context: {content}"));
193 }
194 }
195 }
196 flush(&mut formatted, ¤t_file, &changes);
197
198 let formatted = formatted.trim_end().to_string();
199 truncate_str(&formatted, limit)
200}
201
202pub fn truncate_str(s: &str, limit: usize) -> String {
204 if s.len() <= limit {
205 return s.to_string();
206 }
207 let mut end = limit;
208 while end > 0 && !s.is_char_boundary(end) {
209 end -= 1;
210 }
211 format!("{}\n\n[... diff truncated for brevity ...]", &s[..end])
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn formats_added_and_removed_lines() {
220 let raw = "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context\n";
221 let out = format_diff_for_llm(raw, 12_000);
222 assert!(out.contains("File: src/main.rs"));
223 assert!(out.contains("Added: new line"));
224 assert!(out.contains("Removed: old line"));
225 }
226
227 #[test]
228 fn truncation_is_utf8_safe() {
229 let s = "héllo wörld — ünïcode text that keeps going and going";
230 let out = truncate_str(s, 10);
231 assert!(out.contains("truncated"));
232 assert!(out.len() < s.len() + 60);
234 }
235
236 #[test]
237 fn short_diff_is_not_truncated() {
238 let raw = "diff --git a/a.txt b/a.txt\n@@ -0,0 +1 @@\n+hello\n";
239 let out = format_diff_for_llm(raw, 12_000);
240 assert!(!out.contains("truncated"));
241 }
242}