llm_git/
patch.rs

1use std::process::Command;
2
3use crate::{
4   error::{CommitGenError, Result},
5   types::{ChangeGroup, FileChange},
6};
7
8/// Create a patch for specific files
9pub fn create_patch_for_files(files: &[String], dir: &str) -> Result<String> {
10   let output = Command::new("git")
11      .arg("diff")
12      .arg("HEAD")
13      .arg("--")
14      .args(files)
15      .current_dir(dir)
16      .output()
17      .map_err(|e| CommitGenError::GitError(format!("Failed to create patch: {e}")))?;
18
19   if !output.status.success() {
20      let stderr = String::from_utf8_lossy(&output.stderr);
21      return Err(CommitGenError::GitError(format!("git diff failed: {stderr}")));
22   }
23
24   Ok(String::from_utf8_lossy(&output.stdout).to_string())
25}
26
27/// Apply patch to staging area
28pub fn apply_patch_to_index(patch: &str, dir: &str) -> Result<()> {
29   let mut child = Command::new("git")
30      .args(["apply", "--cached"])
31      .current_dir(dir)
32      .stdin(std::process::Stdio::piped())
33      .stdout(std::process::Stdio::piped())
34      .stderr(std::process::Stdio::piped())
35      .spawn()
36      .map_err(|e| CommitGenError::GitError(format!("Failed to spawn git apply: {e}")))?;
37
38   if let Some(mut stdin) = child.stdin.take() {
39      use std::io::Write;
40      stdin
41         .write_all(patch.as_bytes())
42         .map_err(|e| CommitGenError::GitError(format!("Failed to write patch: {e}")))?;
43   }
44
45   let output = child
46      .wait_with_output()
47      .map_err(|e| CommitGenError::GitError(format!("Failed to wait for git apply: {e}")))?;
48
49   if !output.status.success() {
50      let stderr = String::from_utf8_lossy(&output.stderr);
51      return Err(CommitGenError::GitError(format!("git apply --cached failed: {stderr}")));
52   }
53
54   Ok(())
55}
56
57/// Stage specific files (simpler alternative to patch application)
58pub fn stage_files(files: &[String], dir: &str) -> Result<()> {
59   if files.is_empty() {
60      return Ok(());
61   }
62
63   let output = Command::new("git")
64      .arg("add")
65      .arg("--")
66      .args(files)
67      .current_dir(dir)
68      .output()
69      .map_err(|e| CommitGenError::GitError(format!("Failed to stage files: {e}")))?;
70
71   if !output.status.success() {
72      let stderr = String::from_utf8_lossy(&output.stderr);
73      return Err(CommitGenError::GitError(format!("git add failed: {stderr}")));
74   }
75
76   Ok(())
77}
78
79/// Reset staging area
80pub fn reset_staging(dir: &str) -> Result<()> {
81   let output = Command::new("git")
82      .args(["reset", "HEAD"])
83      .current_dir(dir)
84      .output()
85      .map_err(|e| CommitGenError::GitError(format!("Failed to reset staging: {e}")))?;
86
87   if !output.status.success() {
88      let stderr = String::from_utf8_lossy(&output.stderr);
89      return Err(CommitGenError::GitError(format!("git reset HEAD failed: {stderr}")));
90   }
91
92   Ok(())
93}
94
95/// Extract specific hunks from a full diff for a file
96fn extract_hunks_for_file(
97   full_diff: &str,
98   file_path: &str,
99   hunk_headers: &[String],
100) -> Result<String> {
101   // If "ALL", return entire file diff
102   if hunk_headers.len() == 1 && hunk_headers[0] == "ALL" {
103      return extract_file_diff(full_diff, file_path);
104   }
105
106   let file_diff = extract_file_diff(full_diff, file_path)?;
107   let mut result = String::new();
108   let mut in_header = true;
109   let mut current_hunk = String::new();
110   let mut current_hunk_header = String::new();
111   let mut include_current = false;
112
113   for line in file_diff.lines() {
114      if in_header {
115         result.push_str(line);
116         result.push('\n');
117         if line.starts_with("+++") {
118            in_header = false;
119         }
120      } else if line.starts_with("@@ ") {
121         // Save previous hunk if we were including it
122         if include_current && !current_hunk.is_empty() {
123            result.push_str(&current_hunk);
124         }
125
126         // Start new hunk
127         current_hunk_header = line.to_string();
128         current_hunk = format!("{line}\n");
129
130         // Check if this hunk should be included
131         include_current = hunk_headers.iter().any(|h| {
132            // Normalize comparison - just compare the numeric parts
133            normalize_hunk_header(h) == normalize_hunk_header(&current_hunk_header)
134         });
135      } else {
136         current_hunk.push_str(line);
137         current_hunk.push('\n');
138      }
139   }
140
141   // Don't forget the last hunk
142   if include_current && !current_hunk.is_empty() {
143      result.push_str(&current_hunk);
144   }
145
146   if result
147      .lines()
148      .filter(|l| !l.starts_with("---") && !l.starts_with("+++") && !l.starts_with("diff "))
149      .count()
150      == 0
151   {
152      return Err(CommitGenError::Other(format!(
153         "No hunks found for {file_path} with headers {hunk_headers:?}"
154      )));
155   }
156
157   Ok(result)
158}
159
160/// Normalize hunk header for fuzzy comparison
161/// Extracts line numbers only, ignoring whitespace variations and context
162fn normalize_hunk_header(header: &str) -> String {
163   let trimmed = header.trim();
164
165   // Extract the part between @@ markers
166   let middle = if let Some(start) = trimmed.find("@@") {
167      let after_first = &trimmed[start + 2..];
168      if let Some(end) = after_first.find("@@") {
169         &after_first[..end]
170      } else {
171         after_first
172      }
173   } else {
174      trimmed
175   };
176
177   // Remove all whitespace for fuzzy matching
178   // Keep only: digits, commas, hyphens, plus signs
179   middle
180      .chars()
181      .filter(|c| c.is_ascii_digit() || *c == ',' || *c == '-' || *c == '+')
182      .collect()
183}
184
185/// Extract the diff for a specific file from a full diff
186fn extract_file_diff(full_diff: &str, file_path: &str) -> Result<String> {
187   let mut result = String::new();
188   let mut in_file = false;
189   let mut found = false;
190
191   for line in full_diff.lines() {
192      if line.starts_with("diff --git") {
193         // Check if this is our file
194         if line.contains(&format!("b/{file_path}")) || line.ends_with(&format!(" b/{file_path}")) {
195            in_file = true;
196            found = true;
197            result.push_str(line);
198            result.push('\n');
199         } else {
200            in_file = false;
201         }
202      } else if in_file {
203         result.push_str(line);
204         result.push('\n');
205      }
206   }
207
208   if !found {
209      return Err(CommitGenError::Other(format!("File {file_path} not found in diff")));
210   }
211
212   Ok(result)
213}
214
215/// Create a patch for specific file changes with hunk selection
216pub fn create_patch_for_changes(full_diff: &str, changes: &[FileChange]) -> Result<String> {
217   let mut patch = String::new();
218
219   for change in changes {
220      let file_patch = extract_hunks_for_file(full_diff, &change.path, &change.hunks)?;
221      patch.push_str(&file_patch);
222   }
223
224   Ok(patch)
225}
226
227/// Stage changes for a specific group (hunk-aware).
228/// The `full_diff` argument must be taken before any compose commits run so the
229/// recorded hunk headers remain stable across groups.
230pub fn stage_group_changes(group: &ChangeGroup, dir: &str, full_diff: &str) -> Result<()> {
231   let mut full_files = Vec::new();
232   let mut partial_changes = Vec::new();
233
234   for change in &group.changes {
235      if change.hunks.len() == 1 && change.hunks[0] == "ALL" {
236         full_files.push(change.path.clone());
237      } else {
238         partial_changes.push(change.clone());
239      }
240   }
241
242   if !full_files.is_empty() {
243      // Deduplicate to avoid redundant git add calls
244      full_files.sort();
245      full_files.dedup();
246      stage_files(&full_files, dir)?;
247   }
248
249   if partial_changes.is_empty() {
250      return Ok(());
251   }
252
253   let patch = create_patch_for_changes(full_diff, &partial_changes)?;
254   apply_patch_to_index(&patch, dir)
255}