llm_git/
rewrite.rs

1use std::{fmt, sync::Arc};
2
3use parking_lot::Mutex;
4use rayon::prelude::*;
5
6use crate::{
7   analysis::extract_scope_candidates,
8   api::{generate_conventional_analysis, generate_summary_from_analysis},
9   config::CommitConfig,
10   diff::smart_truncate_diff,
11   error::{CommitGenError, Result},
12   git::{
13      check_working_tree_clean, create_backup_branch, get_commit_list, get_commit_metadata,
14      get_git_diff, get_git_stat, rewrite_history,
15   },
16   normalization::{format_commit_message, post_process_commit_message},
17   types::{Args, CommitMetadata, ConventionalCommit, Mode},
18   validation::validate_commit_message,
19};
20
21/// Run rewrite mode - regenerate all commit messages in history
22pub fn run_rewrite_mode(args: &Args, config: &CommitConfig) -> Result<()> {
23   // 1. Validate preconditions
24   if !args.rewrite_dry_run
25      && args.rewrite_preview.is_none()
26      && !check_working_tree_clean(&args.dir)?
27   {
28      return Err(CommitGenError::Other(
29         "Working directory not clean. Commit or stash changes first.".to_string(),
30      ));
31   }
32
33   // 2. Get commit list
34   println!("šŸ“‹ Collecting commits...");
35   let mut commit_hashes = get_commit_list(args.rewrite_start.as_deref(), &args.dir)?;
36
37   if let Some(n) = args.rewrite_preview {
38      commit_hashes.truncate(n);
39   }
40
41   println!("Found {} commits to process", commit_hashes.len());
42
43   // 3. Extract metadata
44   println!("šŸ” Extracting commit metadata...");
45   let commits: Vec<CommitMetadata> = commit_hashes
46      .iter()
47      .enumerate()
48      .map(|(i, hash)| {
49         if (i + 1) % 50 == 0 {
50            eprintln!("  {}/{}...", i + 1, commit_hashes.len());
51         }
52         get_commit_metadata(hash, &args.dir)
53      })
54      .collect::<Result<Vec<_>>>()?;
55
56   // 4. Preview mode (no API calls)
57   if args.rewrite_dry_run && args.rewrite_preview.is_some() {
58      print_preview_list(&commits);
59      return Ok(());
60   }
61
62   // 5. Generate new messages (parallel)
63   println!("šŸ¤– Converting to conventional commits (parallel={})...\n", args.rewrite_parallel);
64
65   // Force exclude_old_message for rewrite mode
66   let mut rewrite_config = config.clone();
67   rewrite_config.exclude_old_message = true;
68
69   let new_messages = generate_messages_parallel(&commits, &rewrite_config, args)?;
70
71   // 6. Show results
72   print_conversion_results(&commits, &new_messages);
73
74   // 7. Preview or apply
75   if args.rewrite_dry_run {
76      println!("\n=== DRY RUN - No changes made ===");
77      println!("Run without --rewrite-dry-run to apply changes");
78      return Ok(());
79   }
80
81   if args.rewrite_preview.is_some() {
82      println!("\nRun without --rewrite-preview to rewrite all history");
83      return Ok(());
84   }
85
86   // 8. Create backup
87   println!("\nšŸ’¾ Creating backup branch...");
88   let backup = create_backup_branch(&args.dir)?;
89   println!("āœ“ Backup: {backup}");
90
91   // 9. Rewrite history
92   println!("\nāš ļø  Rewriting history...");
93   rewrite_history(&commits, &new_messages, &args.dir)?;
94
95   println!("\nāœ… Done! Rewrote {} commits", commits.len());
96   println!("Restore with: git reset --hard {backup}");
97
98   Ok(())
99}
100
101/// Generate new commit messages in parallel
102fn generate_messages_parallel(
103   commits: &[CommitMetadata],
104   config: &CommitConfig,
105   args: &Args,
106) -> Result<Vec<String>> {
107   let new_messages = Arc::new(Mutex::new(vec![String::new(); commits.len()]));
108   let errors = Arc::new(Mutex::new(Vec::new()));
109
110   rayon::ThreadPoolBuilder::new()
111      .num_threads(args.rewrite_parallel)
112      .build()
113      .map_err(|e| CommitGenError::Other(format!("Failed to create thread pool: {e}")))?
114      .install(|| {
115         commits.par_iter().enumerate().for_each(|(idx, commit)| {
116            match generate_for_commit(commit, config, &args.dir) {
117               Ok(new_msg) => {
118                  new_messages.lock()[idx].clone_from(&new_msg);
119
120                  // Stream output
121                  let old = commit.message.lines().next().unwrap_or("");
122                  let new = new_msg.lines().next().unwrap_or("");
123
124                  println!("[{:3}/{:3}] {}", idx + 1, commits.len(), &commit.hash[..8]);
125                  println!("  - {}", TruncStr(old, 60));
126                  println!("  + {}", TruncStr(new, 60));
127                  println!();
128               },
129               Err(e) => {
130                  eprintln!(
131                     "[{:3}/{:3}] {} āŒ ERROR: {}",
132                     idx + 1,
133                     commits.len(),
134                     &commit.hash[..8],
135                     e
136                  );
137                  // Fallback to original message
138                  new_messages.lock()[idx].clone_from(&commit.message);
139                  errors.lock().push((idx, e.to_string()));
140               },
141            }
142         });
143      });
144
145   let final_messages = Arc::try_unwrap(new_messages).unwrap().into_inner();
146   let error_list = Arc::try_unwrap(errors).unwrap().into_inner();
147
148   if !error_list.is_empty() {
149      eprintln!("\nāš ļø  {} commits failed, kept original messages", error_list.len());
150   }
151
152   Ok(final_messages)
153}
154
155/// Generate conventional commit message for a single commit
156fn generate_for_commit(
157   commit: &CommitMetadata,
158   config: &CommitConfig,
159   dir: &str,
160) -> Result<String> {
161   // Get diff and stat using commit hash as target (exclude old message for
162   // rewrite)
163   let diff = get_git_diff(&Mode::Commit, Some(&commit.hash), dir, config)?;
164   let stat = get_git_stat(&Mode::Commit, Some(&commit.hash), dir, config)?;
165
166   // Truncate if needed
167   let diff = if diff.len() > config.max_diff_length {
168      smart_truncate_diff(&diff, config.max_diff_length, config)
169   } else {
170      diff
171   };
172
173   // Extract scope candidates
174   let (scope_candidates_str, _) =
175      extract_scope_candidates(&Mode::Commit, Some(&commit.hash), dir, config)?;
176
177   // Phase 1: Analysis
178   let analysis = generate_conventional_analysis(
179      &stat,
180      &diff,
181      &config.analysis_model,
182      None, // No user context for bulk rewrite
183      &scope_candidates_str,
184      config,
185   )?;
186
187   // Phase 2: Summary
188   let summary = generate_summary_from_analysis(
189      &stat,
190      analysis.commit_type.as_str(),
191      analysis.scope.as_ref().map(|s| s.as_str()),
192      &analysis.body,
193      None, // No user context in rewrite mode
194      config,
195   )?;
196
197   // Build ConventionalCommit
198   let mut commit_msg = ConventionalCommit {
199      commit_type: analysis.commit_type,
200      scope: analysis.scope,
201      summary,
202      body: analysis.body,
203      footers: analysis
204         .issue_refs
205         .into_iter()
206         .map(|r| format!("Refs {r}"))
207         .collect(),
208   };
209
210   // Post-process and validate
211   post_process_commit_message(&mut commit_msg, config);
212   validate_commit_message(&commit_msg, config)?;
213
214   // Format final message
215   Ok(format_commit_message(&commit_msg))
216}
217
218/// Print preview list of commits (no API calls)
219fn print_preview_list(commits: &[CommitMetadata]) {
220   println!("\n=== PREVIEW - Showing {} commits (no API calls) ===\n", commits.len());
221
222   for (i, commit) in commits.iter().enumerate() {
223      let summary = commit
224         .message
225         .lines()
226         .next()
227         .unwrap_or("")
228         .chars()
229         .take(70)
230         .collect::<String>();
231
232      println!("[{:3}] {} - {}", i + 1, &commit.hash[..8], summary);
233   }
234
235   println!("\nRun without --rewrite-preview to regenerate commits");
236}
237
238/// Print conversion results comparison
239fn print_conversion_results(commits: &[CommitMetadata], new_messages: &[String]) {
240   println!("\nāœ“ Processed {} commits\n", commits.len());
241
242   // Show first 3 examples
243   let show_count = 3.min(commits.len());
244   if show_count > 0 {
245      println!("=== Sample conversions ===\n");
246      for i in 0..show_count {
247         let old = commits[i].message.lines().next().unwrap_or("");
248         let new = new_messages[i].lines().next().unwrap_or("");
249
250         println!("[{}] {}", i + 1, &commits[i].hash[..8]);
251         println!("  - {}", TruncStr(old, 70));
252         println!("  + {}", TruncStr(new, 70));
253         println!();
254      }
255   }
256}
257
258struct TruncStr<'a>(&'a str, usize);
259
260impl fmt::Display for TruncStr<'_> {
261   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262      if self.0.len() <= self.1 {
263         f.write_str(self.0)
264      } else {
265         let n = self.0.floor_char_boundary(self.1);
266         f.write_str(&self.0[..n])?;
267         f.write_str("...")
268      }
269   }
270}