1use std::process::Command;
2
3use crate::{
4 error::{CommitGenError, Result},
5 types::{ChangeGroup, FileChange},
6};
7
8pub 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
27pub 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
57pub 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
79pub 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
95fn extract_hunks_for_file(
97 full_diff: &str,
98 file_path: &str,
99 hunk_headers: &[String],
100) -> Result<String> {
101 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 if include_current && !current_hunk.is_empty() {
123 result.push_str(¤t_hunk);
124 }
125
126 current_hunk_header = line.to_string();
128 current_hunk = format!("{line}\n");
129
130 include_current = hunk_headers.iter().any(|h| {
132 normalize_hunk_header(h) == normalize_hunk_header(¤t_hunk_header)
134 });
135 } else {
136 current_hunk.push_str(line);
137 current_hunk.push('\n');
138 }
139 }
140
141 if include_current && !current_hunk.is_empty() {
143 result.push_str(¤t_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
160fn normalize_hunk_header(header: &str) -> String {
163 let trimmed = header.trim();
164
165 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 middle
180 .chars()
181 .filter(|c| c.is_ascii_digit() || *c == ',' || *c == '-' || *c == '+')
182 .collect()
183}
184
185fn 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 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
215pub 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
227pub 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 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}