Skip to main content

si_git_commit/
git.rs

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    /// Stage specific files, or everything (`git add .`) when empty.
41    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    /// True when there are unstaged or untracked changes.
59    /// Uses `git status --porcelain` so untracked files count
60    /// (plain `git diff --quiet` misses them).
61    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        // Porcelain lines starting with a space or `?` in the worktree
66        // column mean unstaged/untracked work exists. Any line at all
67        // without staged content is also fine to treat via staged check;
68        // here we report "worktree dirty" when any line has worktree state.
69        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    /// Raw staged diff (`git diff --cached`).
87    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
155/// Format a raw unified diff into a compact, LLM-friendly form.
156/// Truncation is UTF-8 boundary safe (the original tool sliced at a byte
157/// offset and could panic on multi-byte characters).
158pub 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, &current_file, &changes);
174            // `diff --git a/path b/path` — take the b/ side.
175            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, &current_file, &changes);
197
198    let formatted = formatted.trim_end().to_string();
199    truncate_str(&formatted, limit)
200}
201
202/// Truncate to at most `limit` bytes without splitting a UTF-8 character.
203pub 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        // No panic and valid UTF-8 by construction.
233        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}