1use std::{collections::HashMap, path::PathBuf, process::Command};
2
3pub use self::git_push as push;
4use crate::{
5 config::CommitConfig,
6 error::{CommitGenError, Result},
7 style,
8 types::{CommitMetadata, Mode},
9};
10
11fn check_index_lock(stderr: &str, dir: &str) -> Option<CommitGenError> {
14 if !stderr.contains("index.lock") {
15 return None;
16 }
17
18 let lock_path = stderr
21 .lines()
22 .find_map(|line| {
23 let start = line.find('\'')?;
24 let end = line[start + 1..].find('\'')?;
25 let path = &line[start + 1..start + 1 + end];
26 if path.ends_with("index.lock") {
27 Some(PathBuf::from(path))
28 } else {
29 None
30 }
31 })
32 .unwrap_or_else(|| PathBuf::from(dir).join(".git/index.lock"));
33
34 Some(CommitGenError::GitIndexLocked { lock_path })
35}
36
37pub fn ensure_git_repo(dir: &str) -> Result<()> {
42 let output = Command::new("git")
43 .args(["rev-parse", "--show-toplevel"])
44 .current_dir(dir)
45 .output()
46 .map_err(|e| CommitGenError::git(format!("Failed to run git rev-parse: {e}")))?;
47
48 if output.status.success() {
49 return Ok(());
50 }
51
52 let stderr = String::from_utf8_lossy(&output.stderr);
53 if stderr.contains("not a git repository") {
54 return Err(CommitGenError::git(
55 "Not a git repository (or any of the parent directories): .git".to_string(),
56 ));
57 }
58
59 Err(CommitGenError::git(format!("Failed to detect git repository: {stderr}")))
60}
61
62pub fn get_git_diff(
64 mode: &Mode,
65 target: Option<&str>,
66 dir: &str,
67 config: &CommitConfig,
68) -> Result<String> {
69 let output = match mode {
70 Mode::Staged => Command::new("git")
71 .args(["diff", "--cached"])
72 .current_dir(dir)
73 .output()
74 .map_err(|e| CommitGenError::git(format!("Failed to run git diff --cached: {e}")))?,
75 Mode::Commit => {
76 let target = target.ok_or_else(|| {
77 CommitGenError::ValidationError("--target required for commit mode".to_string())
78 })?;
79 let mut cmd = Command::new("git");
80 cmd.arg("show");
81 if config.exclude_old_message {
82 cmd.arg("--format=");
83 }
84 cmd.arg(target)
85 .current_dir(dir)
86 .output()
87 .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?
88 },
89 Mode::Unstaged => {
90 let tracked_output = Command::new("git")
92 .args(["diff"])
93 .current_dir(dir)
94 .output()
95 .map_err(|e| CommitGenError::git(format!("Failed to run git diff: {e}")))?;
96
97 if !tracked_output.status.success() {
98 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
99 return Err(CommitGenError::git(format!("git diff failed: {stderr}")));
100 }
101
102 let tracked_diff = String::from_utf8_lossy(&tracked_output.stdout).to_string();
103
104 let untracked_output = Command::new("git")
106 .args(["ls-files", "--others", "--exclude-standard"])
107 .current_dir(dir)
108 .output()
109 .map_err(|e| CommitGenError::git(format!("Failed to list untracked files: {e}")))?;
110
111 if !untracked_output.status.success() {
112 let stderr = String::from_utf8_lossy(&untracked_output.stderr);
113 return Err(CommitGenError::git(format!("git ls-files failed: {stderr}")));
114 }
115
116 let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
117 let untracked_files: Vec<&str> =
118 untracked_list.lines().filter(|s| !s.is_empty()).collect();
119
120 if untracked_files.is_empty() {
121 return Ok(tracked_diff);
122 }
123
124 let mut combined_diff = tracked_diff;
126 for file in untracked_files {
127 let file_diff_output = Command::new("git")
128 .args(["diff", "--no-index", "/dev/null", file])
129 .current_dir(dir)
130 .output()
131 .map_err(|e| {
132 CommitGenError::git(format!("Failed to diff untracked file {file}: {e}"))
133 })?;
134
135 if file_diff_output.status.success() || file_diff_output.status.code() == Some(1) {
137 let file_diff = String::from_utf8_lossy(&file_diff_output.stdout);
138 let lines: Vec<&str> = file_diff.lines().collect();
140 if lines.len() >= 2 {
141 use std::fmt::Write;
142 if !combined_diff.is_empty() {
143 combined_diff.push('\n');
144 }
145 writeln!(combined_diff, "diff --git a/{file} b/{file}").unwrap();
146 combined_diff.push_str("new file mode 100644\n");
147 combined_diff.push_str("index 0000000..0000000\n");
148 combined_diff.push_str("--- /dev/null\n");
149 writeln!(combined_diff, "+++ b/{file}").unwrap();
150 for line in lines.iter().skip(2) {
152 combined_diff.push_str(line);
153 combined_diff.push('\n');
154 }
155 }
156 }
157 }
158
159 return Ok(combined_diff);
160 },
161 Mode::Compose => unreachable!("compose mode handled separately"),
162 };
163
164 if !output.status.success() {
165 let stderr = String::from_utf8_lossy(&output.stderr);
166 return Err(CommitGenError::git(format!("Git command failed: {stderr}")));
167 }
168
169 let diff = String::from_utf8_lossy(&output.stdout).to_string();
170
171 if diff.trim().is_empty() {
172 let mode_str = match mode {
173 Mode::Staged => "staged",
174 Mode::Commit => "commit",
175 Mode::Unstaged => "unstaged",
176 Mode::Compose => "compose",
177 };
178 return Err(CommitGenError::NoChanges { mode: mode_str.to_string() });
179 }
180
181 Ok(diff)
182}
183
184pub fn get_git_stat(
186 mode: &Mode,
187 target: Option<&str>,
188 dir: &str,
189 config: &CommitConfig,
190) -> Result<String> {
191 let output = match mode {
192 Mode::Staged => Command::new("git")
193 .args(["diff", "--cached", "--stat"])
194 .current_dir(dir)
195 .output()
196 .map_err(|e| {
197 CommitGenError::git(format!("Failed to run git diff --cached --stat: {e}"))
198 })?,
199 Mode::Commit => {
200 let target = target.ok_or_else(|| {
201 CommitGenError::ValidationError("--target required for commit mode".to_string())
202 })?;
203 let mut cmd = Command::new("git");
204 cmd.arg("show");
205 if config.exclude_old_message {
206 cmd.arg("--format=");
207 }
208 cmd.arg("--stat")
209 .arg(target)
210 .current_dir(dir)
211 .output()
212 .map_err(|e| CommitGenError::git(format!("Failed to run git show --stat: {e}")))?
213 },
214 Mode::Unstaged => {
215 let tracked_output = Command::new("git")
217 .args(["diff", "--stat"])
218 .current_dir(dir)
219 .output()
220 .map_err(|e| CommitGenError::git(format!("Failed to run git diff --stat: {e}")))?;
221
222 if !tracked_output.status.success() {
223 let stderr = String::from_utf8_lossy(&tracked_output.stderr);
224 return Err(CommitGenError::git(format!("git diff --stat failed: {stderr}")));
225 }
226
227 let mut stat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
228
229 let untracked_output = Command::new("git")
231 .args(["ls-files", "--others", "--exclude-standard"])
232 .current_dir(dir)
233 .output()
234 .map_err(|e| CommitGenError::git(format!("Failed to list untracked files: {e}")))?;
235
236 if !untracked_output.status.success() {
237 let stderr = String::from_utf8_lossy(&untracked_output.stderr);
238 return Err(CommitGenError::git(format!("git ls-files failed: {stderr}")));
239 }
240
241 let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
242 let untracked_files: Vec<&str> =
243 untracked_list.lines().filter(|s| !s.is_empty()).collect();
244
245 if !untracked_files.is_empty() {
246 use std::fmt::Write;
247 for file in untracked_files {
248 use std::fs;
249 if let Ok(metadata) = fs::metadata(format!("{dir}/{file}")) {
250 let lines = if metadata.is_file() {
251 fs::read_to_string(format!("{dir}/{file}"))
252 .map(|content| content.lines().count())
253 .unwrap_or(0)
254 } else {
255 0
256 };
257 if !stat.is_empty() && !stat.ends_with('\n') {
258 stat.push('\n');
259 }
260 writeln!(stat, " {file} | {lines} {}", "+".repeat(lines.min(50))).unwrap();
261 }
262 }
263 }
264
265 return Ok(stat);
266 },
267 Mode::Compose => unreachable!("compose mode handled separately"),
268 };
269
270 if !output.status.success() {
271 let stderr = String::from_utf8_lossy(&output.stderr);
272 return Err(CommitGenError::git(format!("Git stat command failed: {stderr}")));
273 }
274
275 Ok(String::from_utf8_lossy(&output.stdout).to_string())
276}
277
278#[allow(clippy::fn_params_excessive_bools, reason = "commit flags are naturally boolean")]
280pub fn git_commit(
281 message: &str,
282 dry_run: bool,
283 dir: &str,
284 sign: bool,
285 signoff: bool,
286 skip_hooks: bool,
287 amend: bool,
288) -> Result<()> {
289 if dry_run {
290 let sign_flag = if sign { " -S" } else { "" };
291 let signoff_flag = if signoff { " -s" } else { "" };
292 let hooks_flag = if skip_hooks { " --no-verify" } else { "" };
293 let amend_flag = if amend { " --amend" } else { "" };
294 let command = format!(
295 "git commit{sign_flag}{signoff_flag}{hooks_flag}{amend_flag} -m \"{}\"",
296 message.replace('\n', "\\n")
297 );
298 if style::pipe_mode() {
299 eprintln!("\n{}", style::boxed_message("DRY RUN", &command, 60));
300 } else {
301 println!("\n{}", style::boxed_message("DRY RUN", &command, 60));
302 }
303 return Ok(());
304 }
305
306 let mut args = vec!["commit"];
307 if sign {
308 args.push("-S");
309 }
310 if signoff {
311 args.push("-s");
312 }
313 if skip_hooks {
314 args.push("--no-verify");
315 }
316 if amend {
317 args.push("--amend");
318 }
319 args.push("-m");
320 args.push(message);
321
322 let output = Command::new("git")
323 .args(&args)
324 .current_dir(dir)
325 .output()
326 .map_err(|e| CommitGenError::git(format!("Failed to run git commit: {e}")))?;
327
328 if !output.status.success() {
329 let stderr = String::from_utf8_lossy(&output.stderr);
330 let stdout = String::from_utf8_lossy(&output.stdout);
331 if let Some(err) = check_index_lock(&stderr, dir) {
332 return Err(err);
333 }
334 return Err(CommitGenError::git(format!("git commit failed: {stderr}{stdout}")));
335 }
336
337 let stdout = String::from_utf8_lossy(&output.stdout);
338 if style::pipe_mode() {
339 eprintln!("\n{stdout}");
340 eprintln!(
341 "{} {}",
342 style::success(style::icons::SUCCESS),
343 style::success("Successfully committed!")
344 );
345 } else {
346 println!("\n{stdout}");
347 println!(
348 "{} {}",
349 style::success(style::icons::SUCCESS),
350 style::success("Successfully committed!")
351 );
352 }
353
354 Ok(())
355}
356
357pub fn git_push(dir: &str) -> Result<()> {
359 if style::pipe_mode() {
360 eprintln!("\n{}", style::info("Pushing changes..."));
361 } else {
362 println!("\n{}", style::info("Pushing changes..."));
363 }
364
365 let output = Command::new("git")
366 .args(["push"])
367 .current_dir(dir)
368 .output()
369 .map_err(|e| CommitGenError::git(format!("Failed to run git push: {e}")))?;
370
371 if !output.status.success() {
372 let stderr = String::from_utf8_lossy(&output.stderr);
373 let stdout = String::from_utf8_lossy(&output.stdout);
374 return Err(CommitGenError::git(format!(
375 "Git push failed:\nstderr: {stderr}\nstdout: {stdout}"
376 )));
377 }
378
379 let stdout = String::from_utf8_lossy(&output.stdout);
380 let stderr = String::from_utf8_lossy(&output.stderr);
381 if style::pipe_mode() {
382 if !stdout.is_empty() {
383 eprintln!("{stdout}");
384 }
385 if !stderr.is_empty() {
386 eprintln!("{stderr}");
387 }
388 eprintln!(
389 "{} {}",
390 style::success(style::icons::SUCCESS),
391 style::success("Successfully pushed!")
392 );
393 } else {
394 if !stdout.is_empty() {
395 println!("{stdout}");
396 }
397 if !stderr.is_empty() {
398 println!("{stderr}");
399 }
400 println!(
401 "{} {}",
402 style::success(style::icons::SUCCESS),
403 style::success("Successfully pushed!")
404 );
405 }
406
407 Ok(())
408}
409
410pub fn get_head_hash(dir: &str) -> Result<String> {
412 let output = Command::new("git")
413 .args(["rev-parse", "HEAD"])
414 .current_dir(dir)
415 .output()
416 .map_err(|e| CommitGenError::git(format!("Failed to get HEAD hash: {e}")))?;
417
418 if !output.status.success() {
419 let stderr = String::from_utf8_lossy(&output.stderr);
420 return Err(CommitGenError::git(format!("git rev-parse HEAD failed: {stderr}")));
421 }
422
423 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
424}
425
426pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
430 let mut args = vec!["rev-list", "--reverse"];
431 let range;
432 if let Some(start) = start_ref {
433 range = format!("{start}..HEAD");
434 args.push(&range);
435 } else {
436 args.push("HEAD");
437 }
438
439 let output = Command::new("git")
440 .args(&args)
441 .current_dir(dir)
442 .output()
443 .map_err(|e| CommitGenError::git(format!("Failed to run git rev-list: {e}")))?;
444
445 if !output.status.success() {
446 let stderr = String::from_utf8_lossy(&output.stderr);
447 return Err(CommitGenError::git(format!("git rev-list failed: {stderr}")));
448 }
449
450 let stdout = String::from_utf8_lossy(&output.stdout);
451 Ok(stdout.lines().map(|s| s.to_string()).collect())
452}
453
454pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
456 let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
459
460 let info_output = Command::new("git")
461 .args(["show", "-s", &format!("--format={format_str}"), hash])
462 .current_dir(dir)
463 .output()
464 .map_err(|e| CommitGenError::git(format!("Failed to run git show: {e}")))?;
465
466 if !info_output.status.success() {
467 let stderr = String::from_utf8_lossy(&info_output.stderr);
468 return Err(CommitGenError::git(format!("git show failed for {hash}: {stderr}")));
469 }
470
471 let info = String::from_utf8_lossy(&info_output.stdout);
472 let parts: Vec<&str> = info.splitn(7, '\0').collect();
473
474 if parts.len() < 7 {
475 return Err(CommitGenError::git(format!("Failed to parse commit metadata for {hash}")));
476 }
477
478 let tree_output = Command::new("git")
480 .args(["rev-parse", &format!("{hash}^{{tree}}")])
481 .current_dir(dir)
482 .output()
483 .map_err(|e| CommitGenError::git(format!("Failed to get tree hash: {e}")))?;
484 let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
485 .trim()
486 .to_string();
487
488 let parents_output = Command::new("git")
490 .args(["rev-list", "--parents", "-n", "1", hash])
491 .current_dir(dir)
492 .output()
493 .map_err(|e| CommitGenError::git(format!("Failed to get parent hashes: {e}")))?;
494 let parents_line = String::from_utf8_lossy(&parents_output.stdout);
495 let parent_hashes: Vec<String> = parents_line
496 .split_whitespace()
497 .skip(1) .map(|s| s.to_string())
499 .collect();
500
501 Ok(CommitMetadata {
502 hash: hash.to_string(),
503 author_name: parts[0].to_string(),
504 author_email: parts[1].to_string(),
505 author_date: parts[2].to_string(),
506 committer_name: parts[3].to_string(),
507 committer_email: parts[4].to_string(),
508 committer_date: parts[5].to_string(),
509 message: parts[6].trim().to_string(),
510 parent_hashes,
511 tree_hash,
512 })
513}
514
515pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
517 let output = Command::new("git")
518 .args(["status", "--porcelain"])
519 .current_dir(dir)
520 .output()
521 .map_err(|e| CommitGenError::git(format!("Failed to check working tree: {e}")))?;
522
523 Ok(output.stdout.is_empty())
524}
525
526pub fn create_backup_branch(dir: &str) -> Result<String> {
528 use chrono::Local;
529
530 let timestamp = Local::now().format("%Y%m%d-%H%M%S");
531 let backup_name = format!("backup-rewrite-{timestamp}");
532
533 let output = Command::new("git")
534 .args(["branch", &backup_name])
535 .current_dir(dir)
536 .output()
537 .map_err(|e| CommitGenError::git(format!("Failed to create backup branch: {e}")))?;
538
539 if !output.status.success() {
540 let stderr = String::from_utf8_lossy(&output.stderr);
541 return Err(CommitGenError::git(format!("git branch failed: {stderr}")));
542 }
543
544 Ok(backup_name)
545}
546
547pub fn get_recent_commits(dir: &str, count: usize) -> Result<Vec<String>> {
549 let output = Command::new("git")
550 .args(["log", &format!("-{count}"), "--pretty=format:%s"])
551 .current_dir(dir)
552 .output()
553 .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
554
555 if !output.status.success() {
556 let stderr = String::from_utf8_lossy(&output.stderr);
557 return Err(CommitGenError::git(format!("git log failed: {stderr}")));
558 }
559
560 let stdout = String::from_utf8_lossy(&output.stdout);
561 Ok(stdout.lines().map(|s| s.to_string()).collect())
562}
563
564pub fn get_common_scopes(dir: &str, limit: usize) -> Result<Vec<(String, usize)>> {
566 let output = Command::new("git")
567 .args(["log", &format!("-{limit}"), "--pretty=format:%s"])
568 .current_dir(dir)
569 .output()
570 .map_err(|e| CommitGenError::git(format!("Failed to run git log: {e}")))?;
571
572 if !output.status.success() {
573 let stderr = String::from_utf8_lossy(&output.stderr);
574 return Err(CommitGenError::git(format!("git log failed: {stderr}")));
575 }
576
577 let stdout = String::from_utf8_lossy(&output.stdout);
578 let mut scope_counts: HashMap<String, usize> = HashMap::new();
579
580 for line in stdout.lines() {
582 if let Some(scope) = extract_scope_from_commit(line) {
583 *scope_counts.entry(scope).or_insert(0) += 1;
584 }
585 }
586
587 let mut scopes: Vec<(String, usize)> = scope_counts.into_iter().collect();
589 scopes.sort_by(|a, b| b.1.cmp(&a.1));
590
591 Ok(scopes)
592}
593
594fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
596 let parts: Vec<&str> = commit_msg.splitn(2, ':').collect();
598 if parts.len() < 2 {
599 return None;
600 }
601
602 let prefix = parts[0];
603 if let Some(scope_start) = prefix.find('(')
604 && let Some(scope_end) = prefix.find(')')
605 && scope_start < scope_end
606 {
607 return Some(prefix[scope_start + 1..scope_end].to_string());
608 }
609
610 None
611}
612
613#[derive(Debug, Clone)]
615pub struct StylePatterns {
616 pub scope_usage_pct: f32,
618 pub common_verbs: Vec<(String, usize)>,
620 pub avg_length: usize,
622 pub length_range: (usize, usize),
624 pub lowercase_pct: f32,
626 pub top_scopes: Vec<(String, usize)>,
628}
629
630impl StylePatterns {
631 pub fn format_for_prompt(&self) -> String {
633 let mut lines = Vec::new();
634
635 lines.push(format!("Scope usage: {:.0}% of commits use scopes", self.scope_usage_pct));
636
637 if !self.common_verbs.is_empty() {
638 let verbs: Vec<_> = self
639 .common_verbs
640 .iter()
641 .take(5)
642 .map(|(v, c)| format!("{v} ({c})"))
643 .collect();
644 lines.push(format!("Common verbs: {}", verbs.join(", ")));
645 }
646
647 lines.push(format!(
648 "Average length: {} chars (range: {}-{})",
649 self.avg_length, self.length_range.0, self.length_range.1
650 ));
651
652 lines.push(format!("Capitalization: {:.0}% start lowercase", self.lowercase_pct));
653
654 if !self.top_scopes.is_empty() {
655 let scopes: Vec<_> = self
656 .top_scopes
657 .iter()
658 .take(5)
659 .map(|(s, c)| format!("{s} ({c})"))
660 .collect();
661 lines.push(format!("Top scopes: {}", scopes.join(", ")));
662 }
663
664 lines.join("\n")
665 }
666}
667
668pub fn extract_style_patterns(commits: &[String]) -> Option<StylePatterns> {
670 if commits.is_empty() {
671 return None;
672 }
673
674 let mut scope_count = 0;
675 let mut lowercase_count = 0;
676 let mut verb_counts: HashMap<String, usize> = HashMap::new();
677 let mut scope_counts: HashMap<String, usize> = HashMap::new();
678 let mut lengths = Vec::new();
679
680 for commit in commits {
681 if let Some(colon_pos) = commit.find(':') {
683 let prefix = &commit[..colon_pos];
684 let summary = commit[colon_pos + 1..].trim();
685
686 if let Some(paren_start) = prefix.find('(')
688 && let Some(paren_end) = prefix.find(')')
689 {
690 scope_count += 1;
691 let scope = &prefix[paren_start + 1..paren_end];
692 *scope_counts.entry(scope.to_string()).or_insert(0) += 1;
693 }
694
695 if let Some(first_char) = summary.chars().next() {
697 if first_char.is_lowercase() {
698 lowercase_count += 1;
699 }
700
701 let first_word = summary.split_whitespace().next().unwrap_or("");
703 if !first_word.is_empty() {
704 *verb_counts.entry(first_word.to_lowercase()).or_insert(0) += 1;
705 }
706 }
707
708 lengths.push(summary.len());
709 }
710 }
711
712 let total = commits.len();
713 let scope_usage_pct = (scope_count as f32 / total as f32) * 100.0;
714 let lowercase_pct = (lowercase_count as f32 / total as f32) * 100.0;
715
716 let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
718 common_verbs.sort_by(|a, b| b.1.cmp(&a.1));
719
720 let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
722 top_scopes.sort_by(|a, b| b.1.cmp(&a.1));
723
724 let avg_length = if lengths.is_empty() {
726 0
727 } else {
728 lengths.iter().sum::<usize>() / lengths.len()
729 };
730 let length_range = if lengths.is_empty() {
731 (0, 0)
732 } else {
733 (*lengths.iter().min().unwrap_or(&0), *lengths.iter().max().unwrap_or(&0))
734 };
735
736 Some(StylePatterns {
737 scope_usage_pct,
738 common_verbs,
739 avg_length,
740 length_range,
741 lowercase_pct,
742 top_scopes,
743 })
744}
745
746pub fn rewrite_history(
748 commits: &[CommitMetadata],
749 new_messages: &[String],
750 dir: &str,
751) -> Result<()> {
752 if commits.len() != new_messages.len() {
753 return Err(CommitGenError::Other("Commit count mismatch".to_string()));
754 }
755
756 let branch_output = Command::new("git")
758 .args(["rev-parse", "--abbrev-ref", "HEAD"])
759 .current_dir(dir)
760 .output()
761 .map_err(|e| CommitGenError::git(format!("Failed to get current branch: {e}")))?;
762 let current_branch = String::from_utf8_lossy(&branch_output.stdout)
763 .trim()
764 .to_string();
765
766 let mut parent_map: HashMap<String, String> = HashMap::new();
768 let mut new_head: Option<String> = None;
769
770 for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
771 let new_parents: Vec<String> = commit
773 .parent_hashes
774 .iter()
775 .map(|old_parent| {
776 parent_map
777 .get(old_parent)
778 .cloned()
779 .unwrap_or_else(|| old_parent.clone())
780 })
781 .collect();
782
783 let mut cmd = Command::new("git");
785 cmd.arg("commit-tree")
786 .arg(&commit.tree_hash)
787 .arg("-m")
788 .arg(new_msg)
789 .current_dir(dir);
790
791 for parent in &new_parents {
792 cmd.arg("-p").arg(parent);
793 }
794
795 cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
797 .env("GIT_AUTHOR_EMAIL", &commit.author_email)
798 .env("GIT_AUTHOR_DATE", &commit.author_date)
799 .env("GIT_COMMITTER_NAME", &commit.committer_name)
800 .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
801 .env("GIT_COMMITTER_DATE", &commit.committer_date);
802
803 let output = cmd
804 .output()
805 .map_err(|e| CommitGenError::git(format!("Failed to run git commit-tree: {e}")))?;
806
807 if !output.status.success() {
808 let stderr = String::from_utf8_lossy(&output.stderr);
809 return Err(CommitGenError::git(format!(
810 "commit-tree failed for {}: {}",
811 commit.hash, stderr
812 )));
813 }
814
815 let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
816
817 parent_map.insert(commit.hash.clone(), new_hash.clone());
818 new_head = Some(new_hash);
819
820 if (idx + 1) % 50 == 0 {
822 eprintln!(" Rewrote {}/{} commits...", idx + 1, commits.len());
823 }
824 }
825
826 if let Some(head) = new_head {
828 let update_output = Command::new("git")
829 .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
830 .current_dir(dir)
831 .output()
832 .map_err(|e| CommitGenError::git(format!("Failed to update ref: {e}")))?;
833
834 if !update_output.status.success() {
835 let stderr = String::from_utf8_lossy(&update_output.stderr);
836 return Err(CommitGenError::git(format!("git update-ref failed: {stderr}")));
837 }
838
839 let reset_output = Command::new("git")
840 .args(["reset", "--hard", &head])
841 .current_dir(dir)
842 .output()
843 .map_err(|e| CommitGenError::git(format!("Failed to reset: {e}")))?;
844
845 if !reset_output.status.success() {
846 let stderr = String::from_utf8_lossy(&reset_output.stderr);
847 return Err(CommitGenError::git(format!("git reset failed: {stderr}")));
848 }
849 }
850
851 Ok(())
852}