Skip to main content

llm_git/
git.rs

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
11/// Detect a stale `index.lock` from git stderr and return a
12/// [`CommitGenError::GitIndexLocked`] with the resolved path if found.
13fn check_index_lock(stderr: &str, dir: &str) -> Option<CommitGenError> {
14   if !stderr.contains("index.lock") {
15      return None;
16   }
17
18   // Try to extract the exact lock path from the error message.
19   // Git says: "Unable to create '/path/to/.git/index.lock': File exists."
20   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
37/// Ensure the provided directory is inside a git work tree.
38///
39/// # Errors
40/// Returns an error when the directory is not part of a git repository.
41pub 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
62/// Get git diff based on the specified mode
63pub 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         // Get diff for tracked files
91         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         // Get untracked files
105         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         // Generate diffs for untracked files using git diff /dev/null
125         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            // git diff --no-index exits with 1 when files differ (expected)
136            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               // Rewrite the diff header to match standard git format
139               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                  // Skip first 2 lines (---/+++ from --no-index) and copy rest
151                  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
184/// Get git diff --stat to show file-level changes summary
185pub 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         // Get stat for tracked files
216         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         // Get untracked files and append to stat
230         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/// Execute git commit with the given message
279#[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
357/// Execute git push
358pub 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
410/// Get the current HEAD commit hash
411pub 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
426// === History Rewrite Operations ===
427
428/// Get list of commit hashes to rewrite (in chronological order)
429pub 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
454/// Extract complete metadata for a commit (for rewriting)
455pub fn get_commit_metadata(hash: &str, dir: &str) -> Result<CommitMetadata> {
456   // Format: author_name\0author_email\0author_date\0committer_name\
457   // 0committer_email\0committer_date\0message
458   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   // Get tree hash
479   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   // Get parent hashes
489   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) // First is the commit itself
498      .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
515/// Check if working directory is clean
516pub 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
526/// Create timestamped backup branch
527pub 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
547/// Get recent commit messages for style consistency (last N commits)
548pub 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
564/// Extract common scopes from git history by parsing commit messages
565pub 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   // Parse conventional commit format: type(scope): message
581   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   // Sort by frequency (descending)
588   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
594/// Extract scope from a conventional commit message
595fn extract_scope_from_commit(commit_msg: &str) -> Option<String> {
596   // Match pattern: type(scope): message
597   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/// Quantified style patterns extracted from commit history
614#[derive(Debug, Clone)]
615pub struct StylePatterns {
616   /// Percentage of commits using scopes (0.0-100.0)
617   pub scope_usage_pct: f32,
618   /// Common verbs with counts (sorted by count descending)
619   pub common_verbs:    Vec<(String, usize)>,
620   /// Average summary length in chars
621   pub avg_length:      usize,
622   /// Summary length range (min, max)
623   pub length_range:    (usize, usize),
624   /// Percentage of commits starting with lowercase (0.0-100.0)
625   pub lowercase_pct:   f32,
626   /// Top scopes with counts (sorted by count descending)
627   pub top_scopes:      Vec<(String, usize)>,
628}
629
630impl StylePatterns {
631   /// Format patterns for prompt injection
632   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
668/// Extract style patterns from commit history
669pub 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      // Parse: type(scope): summary
682      if let Some(colon_pos) = commit.find(':') {
683         let prefix = &commit[..colon_pos];
684         let summary = commit[colon_pos + 1..].trim();
685
686         // Check for scope
687         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         // Check capitalization of summary
696         if let Some(first_char) = summary.chars().next() {
697            if first_char.is_lowercase() {
698               lowercase_count += 1;
699            }
700
701            // Extract first word as verb
702            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   // Sort verbs by count
717   let mut common_verbs: Vec<_> = verb_counts.into_iter().collect();
718   common_verbs.sort_by(|a, b| b.1.cmp(&a.1));
719
720   // Sort scopes by count
721   let mut top_scopes: Vec<_> = scope_counts.into_iter().collect();
722   top_scopes.sort_by(|a, b| b.1.cmp(&a.1));
723
724   // Calculate length stats
725   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
746/// Rewrite git history with new commit messages
747pub 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   // Get current branch
757   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   // Map old commit hashes to new ones
767   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      // Map old parents to new parents
772      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      // Build commit-tree command
784      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      // Preserve original author/committer metadata
796      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      // Progress reporting
821      if (idx + 1) % 50 == 0 {
822         eprintln!("  Rewrote {}/{} commits...", idx + 1, commits.len());
823      }
824   }
825
826   // Update branch to new head
827   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}