llm_git/
git.rs

1use std::{collections::HashMap, 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
11/// Get git diff based on the specified mode
12pub fn get_git_diff(
13   mode: &Mode,
14   target: Option<&str>,
15   dir: &str,
16   config: &CommitConfig,
17) -> Result<String> {
18   let output = match mode {
19      Mode::Staged => Command::new("git")
20         .args(["diff", "--cached"])
21         .current_dir(dir)
22         .output()
23         .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff --cached: {e}")))?,
24      Mode::Commit => {
25         let target = target.ok_or_else(|| {
26            CommitGenError::ValidationError("--target required for commit mode".to_string())
27         })?;
28         let mut cmd = Command::new("git");
29         cmd.arg("show");
30         if config.exclude_old_message {
31            cmd.arg("--format=");
32         }
33         cmd.arg(target)
34            .current_dir(dir)
35            .output()
36            .map_err(|e| CommitGenError::GitError(format!("Failed to run git show: {e}")))?
37      },
38      Mode::Unstaged => {
39         // Get diff for tracked files
40         let tracked_output = Command::new("git")
41            .args(["diff"])
42            .current_dir(dir)
43            .output()
44            .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff: {e}")))?;
45
46         if !tracked_output.status.success() {
47            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
48            return Err(CommitGenError::GitError(format!("git diff failed: {stderr}")));
49         }
50
51         let tracked_diff = String::from_utf8_lossy(&tracked_output.stdout).to_string();
52
53         // Get untracked files
54         let untracked_output = Command::new("git")
55            .args(["ls-files", "--others", "--exclude-standard"])
56            .current_dir(dir)
57            .output()
58            .map_err(|e| {
59               CommitGenError::GitError(format!("Failed to list untracked files: {e}"))
60            })?;
61
62         if !untracked_output.status.success() {
63            let stderr = String::from_utf8_lossy(&untracked_output.stderr);
64            return Err(CommitGenError::GitError(format!("git ls-files failed: {stderr}")));
65         }
66
67         let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
68         let untracked_files: Vec<&str> =
69            untracked_list.lines().filter(|s| !s.is_empty()).collect();
70
71         if untracked_files.is_empty() {
72            return Ok(tracked_diff);
73         }
74
75         // Generate diffs for untracked files using git diff /dev/null
76         let mut combined_diff = tracked_diff;
77         for file in untracked_files {
78            let file_diff_output = Command::new("git")
79               .args(["diff", "--no-index", "/dev/null", file])
80               .current_dir(dir)
81               .output()
82               .map_err(|e| {
83                  CommitGenError::GitError(format!("Failed to diff untracked file {file}: {e}"))
84               })?;
85
86            // git diff --no-index exits with 1 when files differ (expected)
87            if file_diff_output.status.success() || file_diff_output.status.code() == Some(1) {
88               let file_diff = String::from_utf8_lossy(&file_diff_output.stdout);
89               // Rewrite the diff header to match standard git format
90               let lines: Vec<&str> = file_diff.lines().collect();
91               if lines.len() >= 2 {
92                  use std::fmt::Write;
93                  if !combined_diff.is_empty() {
94                     combined_diff.push('\n');
95                  }
96                  writeln!(combined_diff, "diff --git a/{file} b/{file}").unwrap();
97                  combined_diff.push_str("new file mode 100644\n");
98                  combined_diff.push_str("index 0000000..0000000\n");
99                  combined_diff.push_str("--- /dev/null\n");
100                  writeln!(combined_diff, "+++ b/{file}").unwrap();
101                  // Skip first 2 lines (---/+++ from --no-index) and copy rest
102                  for line in lines.iter().skip(2) {
103                     combined_diff.push_str(line);
104                     combined_diff.push('\n');
105                  }
106               }
107            }
108         }
109
110         return Ok(combined_diff);
111      },
112      Mode::Compose => unreachable!("compose mode handled separately"),
113   };
114
115   if !output.status.success() {
116      let stderr = String::from_utf8_lossy(&output.stderr);
117      return Err(CommitGenError::GitError(format!("Git command failed: {stderr}")));
118   }
119
120   let diff = String::from_utf8_lossy(&output.stdout).to_string();
121
122   if diff.trim().is_empty() {
123      let mode_str = match mode {
124         Mode::Staged => "staged",
125         Mode::Commit => "commit",
126         Mode::Unstaged => "unstaged",
127         Mode::Compose => "compose",
128      };
129      return Err(CommitGenError::NoChanges { mode: mode_str.to_string() });
130   }
131
132   Ok(diff)
133}
134
135/// Get git diff --stat to show file-level changes summary
136pub fn get_git_stat(
137   mode: &Mode,
138   target: Option<&str>,
139   dir: &str,
140   config: &CommitConfig,
141) -> Result<String> {
142   let output = match mode {
143      Mode::Staged => Command::new("git")
144         .args(["diff", "--cached", "--stat"])
145         .current_dir(dir)
146         .output()
147         .map_err(|e| {
148            CommitGenError::GitError(format!("Failed to run git diff --cached --stat: {e}"))
149         })?,
150      Mode::Commit => {
151         let target = target.ok_or_else(|| {
152            CommitGenError::ValidationError("--target required for commit mode".to_string())
153         })?;
154         let mut cmd = Command::new("git");
155         cmd.arg("show");
156         if config.exclude_old_message {
157            cmd.arg("--format=");
158         }
159         cmd.arg("--stat")
160            .arg(target)
161            .current_dir(dir)
162            .output()
163            .map_err(|e| CommitGenError::GitError(format!("Failed to run git show --stat: {e}")))?
164      },
165      Mode::Unstaged => {
166         // Get stat for tracked files
167         let tracked_output = Command::new("git")
168            .args(["diff", "--stat"])
169            .current_dir(dir)
170            .output()
171            .map_err(|e| CommitGenError::GitError(format!("Failed to run git diff --stat: {e}")))?;
172
173         if !tracked_output.status.success() {
174            let stderr = String::from_utf8_lossy(&tracked_output.stderr);
175            return Err(CommitGenError::GitError(format!("git diff --stat failed: {stderr}")));
176         }
177
178         let mut stat = String::from_utf8_lossy(&tracked_output.stdout).to_string();
179
180         // Get untracked files and append to stat
181         let untracked_output = Command::new("git")
182            .args(["ls-files", "--others", "--exclude-standard"])
183            .current_dir(dir)
184            .output()
185            .map_err(|e| {
186               CommitGenError::GitError(format!("Failed to list untracked files: {e}"))
187            })?;
188
189         if !untracked_output.status.success() {
190            let stderr = String::from_utf8_lossy(&untracked_output.stderr);
191            return Err(CommitGenError::GitError(format!("git ls-files failed: {stderr}")));
192         }
193
194         let untracked_list = String::from_utf8_lossy(&untracked_output.stdout);
195         let untracked_files: Vec<&str> =
196            untracked_list.lines().filter(|s| !s.is_empty()).collect();
197
198         if !untracked_files.is_empty() {
199            use std::fmt::Write;
200            for file in untracked_files {
201               use std::fs;
202               if let Ok(metadata) = fs::metadata(format!("{dir}/{file}")) {
203                  let lines = if metadata.is_file() {
204                     fs::read_to_string(format!("{dir}/{file}"))
205                        .map(|content| content.lines().count())
206                        .unwrap_or(0)
207                  } else {
208                     0
209                  };
210                  if !stat.is_empty() && !stat.ends_with('\n') {
211                     stat.push('\n');
212                  }
213                  writeln!(stat, " {file} | {lines} {}", "+".repeat(lines.min(50))).unwrap();
214               }
215            }
216         }
217
218         return Ok(stat);
219      },
220      Mode::Compose => unreachable!("compose mode handled separately"),
221   };
222
223   if !output.status.success() {
224      let stderr = String::from_utf8_lossy(&output.stderr);
225      return Err(CommitGenError::GitError(format!("Git stat command failed: {stderr}")));
226   }
227
228   Ok(String::from_utf8_lossy(&output.stdout).to_string())
229}
230
231/// Execute git commit with the given message
232pub fn git_commit(
233   message: &str,
234   dry_run: bool,
235   dir: &str,
236   sign: bool,
237   skip_hooks: bool,
238) -> Result<()> {
239   if dry_run {
240      let sign_flag = if sign { " -S" } else { "" };
241      let hooks_flag = if skip_hooks { " --no-verify" } else { "" };
242      let command =
243         format!("git commit{sign_flag}{hooks_flag} -m \"{}\"", message.replace('\n', "\\n"));
244      println!("\n{}", style::boxed_message("DRY RUN", &command, 60));
245      return Ok(());
246   }
247
248   let mut args = vec!["commit"];
249   if sign {
250      args.push("-S");
251   }
252   if skip_hooks {
253      args.push("--no-verify");
254   }
255   args.push("-m");
256   args.push(message);
257
258   let output = Command::new("git")
259      .args(&args)
260      .current_dir(dir)
261      .output()
262      .map_err(|e| CommitGenError::GitError(format!("Failed to run git commit: {e}")))?;
263
264   if !output.status.success() {
265      let stderr = String::from_utf8_lossy(&output.stderr);
266      let stdout = String::from_utf8_lossy(&output.stdout);
267      return Err(CommitGenError::GitError(format!(
268         "Git commit failed:\nstderr: {stderr}\nstdout: {stdout}"
269      )));
270   }
271
272   let stdout = String::from_utf8_lossy(&output.stdout);
273   println!("\n{stdout}");
274   println!(
275      "{} {}",
276      style::success(style::icons::SUCCESS),
277      style::success("Successfully committed!")
278   );
279
280   Ok(())
281}
282
283/// Execute git push
284pub fn git_push(dir: &str) -> Result<()> {
285   println!("\n{}", style::info("Pushing changes..."));
286
287   let output = Command::new("git")
288      .args(["push"])
289      .current_dir(dir)
290      .output()
291      .map_err(|e| CommitGenError::GitError(format!("Failed to run git push: {e}")))?;
292
293   if !output.status.success() {
294      let stderr = String::from_utf8_lossy(&output.stderr);
295      let stdout = String::from_utf8_lossy(&output.stdout);
296      return Err(CommitGenError::GitError(format!(
297         "Git push failed:\nstderr: {stderr}\nstdout: {stdout}"
298      )));
299   }
300
301   let stdout = String::from_utf8_lossy(&output.stdout);
302   let stderr = String::from_utf8_lossy(&output.stderr);
303   if !stdout.is_empty() {
304      println!("{stdout}");
305   }
306   if !stderr.is_empty() {
307      println!("{stderr}");
308   }
309   println!("{} {}", style::success(style::icons::SUCCESS), style::success("Successfully pushed!"));
310
311   Ok(())
312}
313
314/// Get the current HEAD commit hash
315pub fn get_head_hash(dir: &str) -> Result<String> {
316   let output = Command::new("git")
317      .args(["rev-parse", "HEAD"])
318      .current_dir(dir)
319      .output()
320      .map_err(|e| CommitGenError::GitError(format!("Failed to get HEAD hash: {e}")))?;
321
322   if !output.status.success() {
323      let stderr = String::from_utf8_lossy(&output.stderr);
324      return Err(CommitGenError::GitError(format!("git rev-parse HEAD failed: {stderr}")));
325   }
326
327   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
328}
329
330// === History Rewrite Operations ===
331
332/// Get list of commit hashes to rewrite (in chronological order)
333pub fn get_commit_list(start_ref: Option<&str>, dir: &str) -> Result<Vec<String>> {
334   let mut args = vec!["rev-list", "--reverse"];
335   let range;
336   if let Some(start) = start_ref {
337      range = format!("{start}..HEAD");
338      args.push(&range);
339   } else {
340      args.push("HEAD");
341   }
342
343   let output = Command::new("git")
344      .args(&args)
345      .current_dir(dir)
346      .output()
347      .map_err(|e| CommitGenError::GitError(format!("Failed to run git rev-list: {e}")))?;
348
349   if !output.status.success() {
350      let stderr = String::from_utf8_lossy(&output.stderr);
351      return Err(CommitGenError::GitError(format!("git rev-list failed: {stderr}")));
352   }
353
354   let stdout = String::from_utf8_lossy(&output.stdout);
355   Ok(stdout.lines().map(|s| s.to_string()).collect())
356}
357
358/// Extract complete metadata for a commit (for rewriting)
359pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
360   // Format: author_name\0author_email\0author_date\0committer_name\
361   // 0committer_email\0committer_date\0message
362   let format_str = "%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B";
363
364   let info_output = Command::new("git")
365      .args(["show", "-s", &format!("--format={format_str}"), hash])
366      .current_dir(dir)
367      .output()
368      .map_err(|e| CommitGenError::GitError(format!("Failed to run git show: {e}")))?;
369
370   if !info_output.status.success() {
371      let stderr = String::from_utf8_lossy(&info_output.stderr);
372      return Err(CommitGenError::GitError(format!("git show failed for {hash}: {stderr}")));
373   }
374
375   let info = String::from_utf8_lossy(&info_output.stdout);
376   let parts: Vec<&str> = info.splitn(7, '\0').collect();
377
378   if parts.len() < 7 {
379      return Err(CommitGenError::GitError(format!("Failed to parse commit metadata for {hash}")));
380   }
381
382   // Get tree hash
383   let tree_output = Command::new("git")
384      .args(["rev-parse", &format!("{hash}^{{tree}}")])
385      .current_dir(dir)
386      .output()
387      .map_err(|e| CommitGenError::GitError(format!("Failed to get tree hash: {e}")))?;
388   let tree_hash = String::from_utf8_lossy(&tree_output.stdout)
389      .trim()
390      .to_string();
391
392   // Get parent hashes
393   let parents_output = Command::new("git")
394      .args(["rev-list", "--parents", "-n", "1", hash])
395      .current_dir(dir)
396      .output()
397      .map_err(|e| CommitGenError::GitError(format!("Failed to get parent hashes: {e}")))?;
398   let parents_line = String::from_utf8_lossy(&parents_output.stdout);
399   let parent_hashes: Vec<String> = parents_line
400      .split_whitespace()
401      .skip(1) // First is the commit itself
402      .map(|s| s.to_string())
403      .collect();
404
405   Ok(CommitMetadata {
406      hash: hash.to_string(),
407      author_name: parts[0].to_string(),
408      author_email: parts[1].to_string(),
409      author_date: parts[2].to_string(),
410      committer_name: parts[3].to_string(),
411      committer_email: parts[4].to_string(),
412      committer_date: parts[5].to_string(),
413      message: parts[6].trim().to_string(),
414      parent_hashes,
415      tree_hash,
416   })
417}
418
419/// Check if working directory is clean
420pub fn check_working_tree_clean(dir: &str) -> Result<bool> {
421   let output = Command::new("git")
422      .args(["status", "--porcelain"])
423      .current_dir(dir)
424      .output()
425      .map_err(|e| CommitGenError::GitError(format!("Failed to check working tree: {e}")))?;
426
427   Ok(output.stdout.is_empty())
428}
429
430/// Create timestamped backup branch
431pub fn create_backup_branch(dir: &str) -> Result<String> {
432   use chrono::Local;
433
434   let timestamp = Local::now().format("%Y%m%d-%H%M%S");
435   let backup_name = format!("backup-rewrite-{timestamp}");
436
437   let output = Command::new("git")
438      .args(["branch", &backup_name])
439      .current_dir(dir)
440      .output()
441      .map_err(|e| CommitGenError::GitError(format!("Failed to create backup branch: {e}")))?;
442
443   if !output.status.success() {
444      let stderr = String::from_utf8_lossy(&output.stderr);
445      return Err(CommitGenError::GitError(format!("git branch failed: {stderr}")));
446   }
447
448   Ok(backup_name)
449}
450
451/// Get recent commit messages for style consistency (last N commits)
452pub fn get_recent_commits(dir: &str, count: usize) -> Result<Vec<String>> {
453   let output = Command::new("git")
454      .args(["log", &format!("-{count}"), "--pretty=format:%s"])
455      .current_dir(dir)
456      .output()
457      .map_err(|e| CommitGenError::GitError(format!("Failed to run git log: {e}")))?;
458
459   if !output.status.success() {
460      let stderr = String::from_utf8_lossy(&output.stderr);
461      return Err(CommitGenError::GitError(format!("git log failed: {stderr}")));
462   }
463
464   let stdout = String::from_utf8_lossy(&output.stdout);
465   Ok(stdout.lines().map(|s| s.to_string()).collect())
466}
467
468/// Extract common scopes from git history by parsing commit messages
469pub fn get_common_scopes(dir: &str, limit: usize) -> Result<Vec<(String, usize)>> {
470   let output = Command::new("git")
471      .args(["log", &format!("-{limit}"), "--pretty=format:%s"])
472      .current_dir(dir)
473      .output()
474      .map_err(|e| CommitGenError::GitError(format!("Failed to run git log: {e}")))?;
475
476   if !output.status.success() {
477      let stderr = String::from_utf8_lossy(&output.stderr);
478      return Err(CommitGenError::GitError(format!("git log failed: {stderr}")));
479   }
480
481   let stdout = String::from_utf8_lossy(&output.stdout);
482   let mut scope_counts: HashMap<String, usize> = HashMap::new();
483
484   // Parse conventional commit format: type(scope): message
485   for line in stdout.lines() {
486      if let Some(scope) = extract_scope_from_commit(line) {
487         *scope_counts.entry(scope).or_insert(0) += 1;
488      }
489   }
490
491   // Sort by frequency (descending)
492   let mut scopes: Vec<(String, usize)> = scope_counts.into_iter().collect();
493   scopes.sort_by(|a, b| b.1.cmp(&a.1));
494
495   Ok(scopes)
496}
497
498/// Extract scope from a conventional commit message
499fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
500   // Match pattern: type(scope): message
501   let parts: Vec<&str> = commit_msg.splitn(2, ':').collect();
502   if parts.len() < 2 {
503      return None;
504   }
505
506   let prefix = parts[0];
507   if let Some(scope_start) = prefix.find('(')
508      && let Some(scope_end) = prefix.find(')')
509      && scope_start < scope_end
510   {
511      return Some(prefix[scope_start + 1..scope_end].to_string());
512   }
513
514   None
515}
516
517/// Quantified style patterns extracted from commit history
518#[derive(Debug, Clone)]
519pub struct StylePatterns {
520   /// Percentage of commits using scopes (0.0-100.0)
521   pub scope_usage_pct: f32,
522   /// Common verbs with counts (sorted by count descending)
523   pub common_verbs:    Vec<(String, usize)>,
524   /// Average summary length in chars
525   pub avg_length:      usize,
526   /// Summary length range (min, max)
527   pub length_range:    (usize, usize),
528   /// Percentage of commits starting with lowercase (0.0-100.0)
529   pub lowercase_pct:   f32,
530   /// Top scopes with counts (sorted by count descending)
531   pub top_scopes:      Vec<(String, usize)>,
532}
533
534impl StylePatterns {
535   /// Format patterns for prompt injection
536   pub fn format_for_prompt(&self) -> String {
537      let mut lines = Vec::new();
538
539      lines.push(format!("Scope usage: {:.0}% of commits use scopes", self.scope_usage_pct));
540
541      if !self.common_verbs.is_empty() {
542         let verbs: Vec<_> = self
543            .common_verbs
544            .iter()
545            .take(5)
546            .map(|(v, c)| format!("{v} ({c})"))
547            .collect();
548         lines.push(format!("Common verbs: {}", verbs.join(", ")));
549      }
550
551      lines.push(format!(
552         "Average length: {} chars (range: {}-{})",
553         self.avg_length, self.length_range.0, self.length_range.1
554      ));
555
556      lines.push(format!("Capitalization: {:.0}% start lowercase", self.lowercase_pct));
557
558      if !self.top_scopes.is_empty() {
559         let scopes: Vec<_> = self
560            .top_scopes
561            .iter()
562            .take(5)
563            .map(|(s, c)| format!("{s} ({c})"))
564            .collect();
565         lines.push(format!("Top scopes: {}", scopes.join(", ")));
566      }
567
568      lines.join("\n")
569   }
570}
571
572/// Extract style patterns from commit history
573pub fn extract_style_patterns(commits: &[String]) -> Option<StylePatterns> {
574   if commits.is_empty() {
575      return None;
576   }
577
578   let mut scope_count = 0;
579   let mut lowercase_count = 0;
580   let mut verb_counts: HashMap<String, usize> = HashMap::new();
581   let mut scope_counts: HashMap<String, usize> = HashMap::new();
582   let mut lengths = Vec::new();
583
584   for commit in commits {
585      // Parse: type(scope): summary
586      if let Some(colon_pos) = commit.find(':') {
587         let prefix = &commit[..colon_pos];
588         let summary = commit[colon_pos + 1..].trim();
589
590         // Check for scope
591         if let Some(paren_start) = prefix.find('(')
592            && let Some(paren_end) = prefix.find(')')
593         {
594            scope_count += 1;
595            let scope = &prefix[paren_start + 1..paren_end];
596            *scope_counts.entry(scope.to_string()).or_insert(0) += 1;
597         }
598
599         // Check capitalization of summary
600         if let Some(first_char) = summary.chars().next() {
601            if first_char.is_lowercase() {
602               lowercase_count += 1;
603            }
604
605            // Extract first word as verb
606            let first_word = summary.split_whitespace().next().unwrap_or("");
607            if !first_word.is_empty() {
608               *verb_counts.entry(first_word.to_lowercase()).or_insert(0) += 1;
609            }
610         }
611
612         lengths.push(summary.len());
613      }
614   }
615
616   let total = commits.len();
617   let scope_usage_pct = (scope_count as f32 / total as f32) * 100.0;
618   let lowercase_pct = (lowercase_count as f32 / total as f32) * 100.0;
619
620   // Sort verbs by count
621   let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
622   common_verbs.sort_by(|a, b| b.1.cmp(&a.1));
623
624   // Sort scopes by count
625   let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
626   top_scopes.sort_by(|a, b| b.1.cmp(&a.1));
627
628   // Calculate length stats
629   let avg_length = if lengths.is_empty() {
630      0
631   } else {
632      lengths.iter().sum::<usize>() / lengths.len()
633   };
634   let length_range = if lengths.is_empty() {
635      (0, 0)
636   } else {
637      (*lengths.iter().min().unwrap_or(&0), *lengths.iter().max().unwrap_or(&0))
638   };
639
640   Some(StylePatterns {
641      scope_usage_pct,
642      common_verbs,
643      avg_length,
644      length_range,
645      lowercase_pct,
646      top_scopes,
647   })
648}
649
650/// Rewrite git history with new commit messages
651pub fn rewrite_history(
652   commits: &[CommitMetadata],
653   new_messages: &[String],
654   dir: &str,
655) -> Result<()> {
656   if commits.len() != new_messages.len() {
657      return Err(CommitGenError::Other("Commit count mismatch".to_string()));
658   }
659
660   // Get current branch
661   let branch_output = Command::new("git")
662      .args(["rev-parse", "--abbrev-ref", "HEAD"])
663      .current_dir(dir)
664      .output()
665      .map_err(|e| CommitGenError::GitError(format!("Failed to get current branch: {e}")))?;
666   let current_branch = String::from_utf8_lossy(&branch_output.stdout)
667      .trim()
668      .to_string();
669
670   // Map old commit hashes to new ones
671   let mut parent_map: HashMap<String, String> = HashMap::new();
672   let mut new_head: Option<String> = None;
673
674   for (idx, (commit, new_msg)) in commits.iter().zip(new_messages.iter()).enumerate() {
675      // Map old parents to new parents
676      let new_parents: Vec<String> = commit
677         .parent_hashes
678         .iter()
679         .map(|old_parent| {
680            parent_map
681               .get(old_parent)
682               .cloned()
683               .unwrap_or_else(|| old_parent.clone())
684         })
685         .collect();
686
687      // Build commit-tree command
688      let mut cmd = Command::new("git");
689      cmd.arg("commit-tree")
690         .arg(&commit.tree_hash)
691         .arg("-m")
692         .arg(new_msg)
693         .current_dir(dir);
694
695      for parent in &new_parents {
696         cmd.arg("-p").arg(parent);
697      }
698
699      // Preserve original author/committer metadata
700      cmd.env("GIT_AUTHOR_NAME", &commit.author_name)
701         .env("GIT_AUTHOR_EMAIL", &commit.author_email)
702         .env("GIT_AUTHOR_DATE", &commit.author_date)
703         .env("GIT_COMMITTER_NAME", &commit.committer_name)
704         .env("GIT_COMMITTER_EMAIL", &commit.committer_email)
705         .env("GIT_COMMITTER_DATE", &commit.committer_date);
706
707      let output = cmd
708         .output()
709         .map_err(|e| CommitGenError::GitError(format!("Failed to run git commit-tree: {e}")))?;
710
711      if !output.status.success() {
712         let stderr = String::from_utf8_lossy(&output.stderr);
713         return Err(CommitGenError::GitError(format!(
714            "commit-tree failed for {}: {}",
715            commit.hash, stderr
716         )));
717      }
718
719      let new_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
720
721      parent_map.insert(commit.hash.clone(), new_hash.clone());
722      new_head = Some(new_hash);
723
724      // Progress reporting
725      if (idx + 1) % 50 == 0 {
726         eprintln!("  Rewrote {}/{} commits...", idx + 1, commits.len());
727      }
728   }
729
730   // Update branch to new head
731   if let Some(head) = new_head {
732      let update_output = Command::new("git")
733         .args(["update-ref", &format!("refs/heads/{current_branch}"), &head])
734         .current_dir(dir)
735         .output()
736         .map_err(|e| CommitGenError::GitError(format!("Failed to update ref: {e}")))?;
737
738      if !update_output.status.success() {
739         let stderr = String::from_utf8_lossy(&update_output.stderr);
740         return Err(CommitGenError::GitError(format!("git update-ref failed: {stderr}")));
741      }
742
743      let reset_output = Command::new("git")
744         .args(["reset", "--hard", &head])
745         .current_dir(dir)
746         .output()
747         .map_err(|e| CommitGenError::GitError(format!("Failed to reset: {e}")))?;
748
749      if !reset_output.status.success() {
750         let stderr = String::from_utf8_lossy(&reset_output.stderr);
751         return Err(CommitGenError::GitError(format!("git reset failed: {stderr}")));
752      }
753   }
754
755   Ok(())
756}