Skip to main content

llm_git/
rewrite.rs

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