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