1use std::{collections::HashMap, process::Command};
2
3use crate::{
4 config::CommitConfig,
5 error::{CommitGenError, Result},
6 types::{CommitMetadata, Mode},
7};
8
9pub fn get_git_diff(
11 mode: &Mode,
12 target: Option<&str>,
13 dir: &str,
14 config: &CommitConfig,
15) -> Result<String> {
16 let output = match mode {
17 Mode::Staged => Command::new("git")
18 .args(["diff", "--cached"])
19 .current_dir(dir)
20 .output()
21 .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff --cached: {e}")))?,
22 Mode::Commit => {
23 let target = target.ok_or_else(|| {
24 CommitGenError::ValidationError("--target required for commit mode".to_string())
25 })?;
26 let mut cmd = Command::new("git");
27 cmd.arg("show");
28 if config.exclude_old_message {
29 cmd.arg("--format=");
30 }
31 cmd.arg(target)
32 .current_dir(dir)
33 .output()
34 .map_err(|e| CommitGenError::GitError(format!("Failed to run git show: {e}")))?
35 },
36 Mode::Unstaged => {
37 let tracked_output = Command::new("git")
39 .args(["diff"])
40 .current_dir(dir)
41 .output()
42 .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff: {e}")))?;
43
44 if !tracked_output.status.success() {
45 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
46 return Err(CommitGenError::GitError(format!("git diff failed: {stderr}")));
47 }
48
49 let tracked_diff = String::from_utf8_lossy(&tracked_output.stdout).to_string();
50
51 let untracked_output = Command::new("git")
53 .args(["ls-files", "--others", "--exclude-standard"])
54 .current_dir(dir)
55 .output()
56 .map_err(|e| {
57 CommitGenError::GitError(format!("Failed to list untracked files: {e}"))
58 })?;
59
60 if !untracked_output.status.success() {
61 let stderr = String::from_utf8_lossy(&untracked_output.stderr);
62 return Err(CommitGenError::GitError(format!("git ls-files failed: {stderr}")));
63 }
64
65 let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
66 let untracked_files: Vec<&str> =
67 untracked_list.lines().filter(|s| !s.is_empty()).collect();
68
69 if untracked_files.is_empty() {
70 return Ok(tracked_diff);
71 }
72
73 let mut combined_diff = tracked_diff;
75 for file in untracked_files {
76 let file_diff_output = Command::new("git")
77 .args(["diff", "--no-index", "/dev/null", file])
78 .current_dir(dir)
79 .output()
80 .map_err(|e| {
81 CommitGenError::GitError(format!("Failed to diff untracked file {file}: {e}"))
82 })?;
83
84 if file_diff_output.status.success() || file_diff_output.status.code() == Some(1) {
86 let file_diff = String::from_utf8_lossy(&file_diff_output.stdout);
87 let lines: Vec<&str> = file_diff.lines().collect();
89 if lines.len() >= 2 {
90 use std::fmt::Write;
91 if !combined_diff.is_empty() {
92 combined_diff.push('\n');
93 }
94 writeln!(combined_diff, "diff --git a/{file} b/{file}").unwrap();
95 combined_diff.push_str("new file mode 100644\n");
96 combined_diff.push_str("index 0000000..0000000\n");
97 combined_diff.push_str("--- /dev/null\n");
98 writeln!(combined_diff, "+++ b/{file}").unwrap();
99 for line in lines.iter().skip(2) {
101 combined_diff.push_str(line);
102 combined_diff.push('\n');
103 }
104 }
105 }
106 }
107
108 return Ok(combined_diff);
109 },
110 Mode::Compose => unreachable!("compose mode handled separately"),
111 };
112
113 if !output.status.success() {
114 let stderr = String::from_utf8_lossy(&output.stderr);
115 return Err(CommitGenError::GitError(format!("Git command failed: {stderr}")));
116 }
117
118 let diff = String::from_utf8_lossy(&output.stdout).to_string();
119
120 if diff.trim().is_empty() {
121 let mode_str = match mode {
122 Mode::Staged => "staged",
123 Mode::Commit => "commit",
124 Mode::Unstaged => "unstaged",
125 Mode::Compose => "compose",
126 };
127 return Err(CommitGenError::NoChanges { mode: mode_str.to_string() });
128 }
129
130 Ok(diff)
131}
132
133pub fn get_git_stat(
135 mode: &Mode,
136 target: Option<&str>,
137 dir: &str,
138 config: &CommitConfig,
139) -> Result<String> {
140 let output = match mode {
141 Mode::Staged => Command::new("git")
142 .args(["diff", "--cached", "--stat"])
143 .current_dir(dir)
144 .output()
145 .map_err(|e| {
146 CommitGenError::GitError(format!("Failed to run git diff --cached --stat: {e}"))
147 })?,
148 Mode::Commit => {
149 let target = target.ok_or_else(|| {
150 CommitGenError::ValidationError("--target required for commit mode".to_string())
151 })?;
152 let mut cmd = Command::new("git");
153 cmd.arg("show");
154 if config.exclude_old_message {
155 cmd.arg("--format=");
156 }
157 cmd.arg("--stat")
158 .arg(target)
159 .current_dir(dir)
160 .output()
161 .map_err(|e| CommitGenError::GitError(format!("Failed to run git show --stat: {e}")))?
162 },
163 Mode::Unstaged => {
164 let tracked_output = Command::new("git")
166 .args(["diff", "--stat"])
167 .current_dir(dir)
168 .output()
169 .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff --stat: {e}")))?;
170
171 if !tracked_output.status.success() {
172 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
173 return Err(CommitGenError::GitError(format!("git diff --stat failed: {stderr}")));
174 }
175
176 let mut stat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
177
178 let untracked_output = Command::new("git")
180 .args(["ls-files", "--others", "--exclude-standard"])
181 .current_dir(dir)
182 .output()
183 .map_err(|e| {
184 CommitGenError::GitError(format!("Failed to list untracked files: {e}"))
185 })?;
186
187 if !untracked_output.status.success() {
188 let stderr = String::from_utf8_lossy(&untracked_output.stderr);
189 return Err(CommitGenError::GitError(format!("git ls-files failed: {stderr}")));
190 }
191
192 let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
193 let untracked_files: Vec<&str> =
194 untracked_list.lines().filter(|s| !s.is_empty()).collect();
195
196 if !untracked_files.is_empty() {
197 use std::fmt::Write;
198 for file in untracked_files {
199 use std::fs;
200 if let Ok(metadata) = fs::metadata(format!("{dir}/{file}")) {
201 let lines = if metadata.is_file() {
202 fs::read_to_string(format!("{dir}/{file}"))
203 .map(|content| content.lines().count())
204 .unwrap_or(0)
205 } else {
206 0
207 };
208 if !stat.is_empty() && !stat.ends_with('\n') {
209 stat.push('\n');
210 }
211 writeln!(stat, " {file} | {lines} {}", "+".repeat(lines.min(50))).unwrap();
212 }
213 }
214 }
215
216 return Ok(stat);
217 },
218 Mode::Compose => unreachable!("compose mode handled separately"),
219 };
220
221 if !output.status.success() {
222 let stderr = String::from_utf8_lossy(&output.stderr);
223 return Err(CommitGenError::GitError(format!("Git stat command failed: {stderr}")));
224 }
225
226 Ok(String::from_utf8_lossy(&output.stdout).to_string())
227}
228
229pub fn git_commit(message: &str, dry_run: bool, dir: &str) -> Result<()> {
231 if dry_run {
232 println!("\n{}", "=".repeat(60));
233 println!("DRY RUN - Would execute:");
234 println!("git commit -m \"{}\"", message.replace('\n', "\\n"));
235 println!("{}", "=".repeat(60));
236 return Ok(());
237 }
238
239 let output = Command::new("git")
240 .args(["commit", "-m", message])
241 .current_dir(dir)
242 .output()
243 .map_err(|e| CommitGenError::GitError(format!("Failed to run git commit: {e}")))?;
244
245 if !output.status.success() {
246 let stderr = String::from_utf8_lossy(&output.stderr);
247 let stdout = String::from_utf8_lossy(&output.stdout);
248 return Err(CommitGenError::GitError(format!(
249 "Git commit failed:\nstderr: {stderr}\nstdout: {stdout}"
250 )));
251 }
252
253 let stdout = String::from_utf8_lossy(&output.stdout);
254 println!("\n{stdout}");
255 println!("✓ Successfully committed!");
256
257 Ok(())
258}
259
260pub fn get_head_hash(dir: &str) -> Result<String> {
262 let output = Command::new("git")
263 .args(["rev-parse", "HEAD"])
264 .current_dir(dir)
265 .output()
266 .map_err(|e| CommitGenError::GitError(format!("Failed to get HEAD hash: {e}")))?;
267
268 if !output.status.success() {
269 let stderr = String::from_utf8_lossy(&output.stderr);
270 return Err(CommitGenError::GitError(format!("git rev-parse HEAD failed: {stderr}")));
271 }
272
273 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
274}
275
276pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
280 let mut args = vec!["rev-list", "--reverse"];
281 let range;
282 if let Some(start) = start_ref {
283 range = format!("{start}..HEAD");
284 args.push(&range);
285 } else {
286 args.push("HEAD");
287 }
288
289 let output = Command::new("git")
290 .args(&args)
291 .current_dir(dir)
292 .output()
293 .map_err(|e| CommitGenError::GitError(format!("Failed to run git rev-list: {e}")))?;
294
295 if !output.status.success() {
296 let stderr = String::from_utf8_lossy(&output.stderr);
297 return Err(CommitGenError::GitError(format!("git rev-list failed: {stderr}")));
298 }
299
300 let stdout = String::from_utf8_lossy(&output.stdout);
301 Ok(stdout.lines().map(|s| s.to_string()).collect())
302}
303
304pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
306 let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
309
310 let info_output = Command::new("git")
311 .args(["show", "-s", &format!("--format={format_str}"), hash])
312 .current_dir(dir)
313 .output()
314 .map_err(|e| CommitGenError::GitError(format!("Failed to run git show: {e}")))?;
315
316 if !info_output.status.success() {
317 let stderr = String::from_utf8_lossy(&info_output.stderr);
318 return Err(CommitGenError::GitError(format!("git show failed for {hash}: {stderr}")));
319 }
320
321 let info = String::from_utf8_lossy(&info_output.stdout);
322 let parts: Vec<&str> = info.splitn(7, '\0').collect();
323
324 if parts.len() < 7 {
325 return Err(CommitGenError::GitError(format!("Failed to parse commit metadata for {hash}")));
326 }
327
328 let tree_output = Command::new("git")
330 .args(["rev-parse", &format!("{hash}^{{tree}}")])
331 .current_dir(dir)
332 .output()
333 .map_err(|e| CommitGenError::GitError(format!("Failed to get tree hash: {e}")))?;
334 let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
335 .trim()
336 .to_string();
337
338 let parents_output = Command::new("git")
340 .args(["rev-list", "--parents", "-n", "1", hash])
341 .current_dir(dir)
342 .output()
343 .map_err(|e| CommitGenError::GitError(format!("Failed to get parent hashes: {e}")))?;
344 let parents_line = String::from_utf8_lossy(&parents_output.stdout);
345 let parent_hashes: Vec<String> = parents_line
346 .split_whitespace()
347 .skip(1) .map(|s| s.to_string())
349 .collect();
350
351 Ok(CommitMetadata {
352 hash: hash.to_string(),
353 author_name: parts[0].to_string(),
354 author_email: parts[1].to_string(),
355 author_date: parts[2].to_string(),
356 committer_name: parts[3].to_string(),
357 committer_email: parts[4].to_string(),
358 committer_date: parts[5].to_string(),
359 message: parts[6].trim().to_string(),
360 parent_hashes,
361 tree_hash,
362 })
363}
364
365pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
367 let output = Command::new("git")
368 .args(["status", "--porcelain"])
369 .current_dir(dir)
370 .output()
371 .map_err(|e| CommitGenError::GitError(format!("Failed to check working tree: {e}")))?;
372
373 Ok(output.stdout.is_empty())
374}
375
376pub fn create_backup_branch(dir: &str) -> Result<String> {
378 use chrono::Local;
379
380 let timestamp = Local::now().format("%Y%m%d-%H%M%S");
381 let backup_name = format!("backup-rewrite-{timestamp}");
382
383 let output = Command::new("git")
384 .args(["branch", &backup_name])
385 .current_dir(dir)
386 .output()
387 .map_err(|e| CommitGenError::GitError(format!("Failed to create backup branch: {e}")))?;
388
389 if !output.status.success() {
390 let stderr = String::from_utf8_lossy(&output.stderr);
391 return Err(CommitGenError::GitError(format!("git branch failed: {stderr}")));
392 }
393
394 Ok(backup_name)
395}
396
397pub fn rewrite_history(
399 commits: &[CommitMetadata],
400 new_messages: &[String],
401 dir: &str,
402) -> Result<()> {
403 if commits.len() != new_messages.len() {
404 return Err(CommitGenError::Other("Commit count mismatch".to_string()));
405 }
406
407 let branch_output = Command::new("git")
409 .args(["rev-parse", "--abbrev-ref", "HEAD"])
410 .current_dir(dir)
411 .output()
412 .map_err(|e| CommitGenError::GitError(format!("Failed to get current branch: {e}")))?;
413 let current_branch = String::from_utf8_lossy(&branch_output.stdout)
414 .trim()
415 .to_string();
416
417 let mut parent_map: HashMap<String, String> = HashMap::new();
419 let mut new_head: Option<String> = None;
420
421 for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
422 let new_parents: Vec<String> = commit
424 .parent_hashes
425 .iter()
426 .map(|old_parent| {
427 parent_map
428 .get(old_parent)
429 .cloned()
430 .unwrap_or_else(|| old_parent.clone())
431 })
432 .collect();
433
434 let mut cmd = Command::new("git");
436 cmd.arg("commit-tree")
437 .arg(&commit.tree_hash)
438 .arg("-m")
439 .arg(new_msg)
440 .current_dir(dir);
441
442 for parent in &new_parents {
443 cmd.arg("-p").arg(parent);
444 }
445
446 cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
448 .env("GIT_AUTHOR_EMAIL", &commit.author_email)
449 .env("GIT_AUTHOR_DATE", &commit.author_date)
450 .env("GIT_COMMITTER_NAME", &commit.committer_name)
451 .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
452 .env("GIT_COMMITTER_DATE", &commit.committer_date);
453
454 let output = cmd
455 .output()
456 .map_err(|e| CommitGenError::GitError(format!("Failed to run git commit-tree: {e}")))?;
457
458 if !output.status.success() {
459 let stderr = String::from_utf8_lossy(&output.stderr);
460 return Err(CommitGenError::GitError(format!(
461 "commit-tree failed for {}: {}",
462 commit.hash, stderr
463 )));
464 }
465
466 let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
467
468 parent_map.insert(commit.hash.clone(), new_hash.clone());
469 new_head = Some(new_hash);
470
471 if (idx + 1) % 50 == 0 {
473 eprintln!(" Rewrote {}/{} commits...", idx + 1, commits.len());
474 }
475 }
476
477 if let Some(head) = new_head {
479 let update_output = Command::new("git")
480 .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
481 .current_dir(dir)
482 .output()
483 .map_err(|e| CommitGenError::GitError(format!("Failed to update ref: {e}")))?;
484
485 if !update_output.status.success() {
486 let stderr = String::from_utf8_lossy(&update_output.stderr);
487 return Err(CommitGenError::GitError(format!("git update-ref failed: {stderr}")));
488 }
489
490 let reset_output = Command::new("git")
491 .args(["reset", "--hard", &head])
492 .current_dir(dir)
493 .output()
494 .map_err(|e| CommitGenError::GitError(format!("Failed to reset: {e}")))?;
495
496 if !reset_output.status.success() {
497 let stderr = String::from_utf8_lossy(&reset_output.stderr);
498 return Err(CommitGenError::GitError(format!("git reset failed: {stderr}")));
499 }
500 }
501
502 Ok(())
503}