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
23#[tracing::instrument(target = "lgit", name = "rewrite.run", skip_all, fields(dir = %args.dir, parallel = args.rewrite_parallel, dry_run = args.rewrite_dry_run))]
24pub async 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).await?;
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 using async streams
112#[tracing::instrument(target = "lgit", name = "rewrite.generate_messages_parallel", skip_all, fields(commit_count = commits.len(), parallel = args.rewrite_parallel))]
113async fn generate_messages_parallel(
114   commits: &[CommitMetadata],
115   config: &CommitConfig,
116   args: &Args,
117) -> Result<Vec<String>> {
118   let mut results = vec![String::new(); commits.len()];
119   let mut errors = Vec::new();
120
121   let outputs: Vec<(usize, std::result::Result<String, CommitGenError>)> = stream::iter(
122      commits.iter().enumerate(),
123   )
124   .map(|(idx, commit)| async move { (idx, generate_for_commit(commit, config, &args.dir).await) })
125   .buffer_unordered(args.rewrite_parallel)
126   .collect()
127   .await;
128
129   for (idx, result) in outputs {
130      match result {
131         Ok(new_msg) => {
132            let old = commits[idx].message.lines().next().unwrap_or("");
133            let new = new_msg.lines().next().unwrap_or("");
134            println!("[{:3}/{:3}] {}", idx + 1, commits.len(), style::dim(&commits[idx].hash[..8]));
135            println!("  {} {}", style::error("-"), style::dim(&TruncStr(old, 60).to_string()));
136            println!("  {} {}", style::success("+"), TruncStr(new, 60));
137            println!();
138            results[idx].clone_from(&new_msg);
139         },
140         Err(e) => {
141            eprintln!(
142               "[{:3}/{:3}] {} {} {}",
143               idx + 1,
144               commits.len(),
145               style::dim(&commits[idx].hash[..8]),
146               style::error("❌ ERROR:"),
147               e
148            );
149            results[idx].clone_from(&commits[idx].message);
150            errors.push((idx, e.to_string()));
151         },
152      }
153   }
154
155   if !errors.is_empty() {
156      eprintln!(
157         "\n{} {} commits failed, kept original messages",
158         style::warning("⚠\u{fe0f}"),
159         style::bold(&errors.len().to_string())
160      );
161   }
162
163   Ok(results)
164}
165
166/// Generate conventional commit message for a single commit
167#[tracing::instrument(target = "lgit", name = "rewrite.generate_for_commit", skip_all, fields(dir, hash = %commit.hash))]
168async fn generate_for_commit(
169   commit: &CommitMetadata,
170   config: &CommitConfig,
171   dir: &str,
172) -> Result<String> {
173   let token_counter = create_token_counter(config);
174   // rewrite)
175   let diff = get_git_diff(&Mode::Commit, Some(&commit.hash), dir, config)?;
176   let stat = get_git_stat(&Mode::Commit, Some(&commit.hash), dir, config)?;
177   let diff = if diff.len() > config.max_diff_length {
178      smart_truncate_diff(&diff, config.max_diff_length, config, &token_counter)
179   } else {
180      diff
181   };
182   // Extract scope candidates
183   let (scope_candidates_str, _) =
184      extract_scope_candidates(&Mode::Commit, Some(&commit.hash), dir, config)?;
185   let ctx = AnalysisContext {
186      user_context:    None, // No user context for bulk rewrite
187      recent_commits:  None, // No recent commits for rewrite mode
188      common_scopes:   None, // No common scopes for rewrite mode
189      project_context: None, // No project context for rewrite mode
190      debug_output:    None,
191      debug_prefix:    None,
192   };
193   let analysis = generate_conventional_analysis(
194      &stat,
195      &diff,
196      &config.analysis_model,
197      &scope_candidates_str,
198      &ctx,
199      config,
200   )
201   .await?;
202
203   // Phase 2: Summary
204   let body_texts = analysis.body_texts();
205   let summary = generate_summary_from_analysis(
206      &stat,
207      analysis.commit_type.as_str(),
208      analysis.scope.as_ref().map(|s| s.as_str()),
209      &body_texts,
210      None, // No user context in rewrite mode
211      config,
212      None,
213      None,
214   )
215   .await?;
216   // Build ConventionalCommit
217   // Issue refs are now inlined in body items, so footers are empty (unless added
218   // by CLI)
219   let mut commit_msg = ConventionalCommit {
220      commit_type: analysis.commit_type,
221      scope: analysis.scope,
222      summary,
223      body: body_texts,
224      footers: vec![], // Issue refs are inlined in body items now
225   };
226
227   // Post-process and validate
228   post_process_commit_message(&mut commit_msg, config);
229   validate_commit_message(&commit_msg, config)?;
230
231   // Format final message
232   Ok(format_commit_message(&commit_msg))
233}
234
235/// Print preview list of commits (no API calls)
236#[tracing::instrument(target = "lgit", name = "rewrite.print_preview_list", skip_all, fields(commit_count = commits.len()))]
237fn print_preview_list(commits: &[CommitMetadata]) {
238   println!(
239      "\n{}\n",
240      style::section_header(
241         &format!("PREVIEW - Showing {} commits (no API calls)", commits.len()),
242         70
243      )
244   );
245
246   for (i, commit) in commits.iter().enumerate() {
247      let summary = commit
248         .message
249         .lines()
250         .next()
251         .unwrap_or("")
252         .chars()
253         .take(70)
254         .collect::<String>();
255
256      println!("[{:3}] {} - {}", i + 1, style::dim(&commit.hash[..8]), summary);
257   }
258
259   println!("\n{}", style::dim("Run without --rewrite-preview to regenerate commits"));
260}
261
262/// Print conversion results comparison
263#[tracing::instrument(target = "lgit", name = "rewrite.print_conversion_results", skip_all, fields(commit_count = commits.len()))]
264fn print_conversion_results(commits: &[CommitMetadata], new_messages: &[String]) {
265   println!(
266      "\n{} Processed {} commits\n",
267      style::success("✓"),
268      style::bold(&commits.len().to_string())
269   );
270
271   // Show first 3 examples
272   let show_count = 3.min(commits.len());
273   if show_count > 0 {
274      println!("{}\n", style::section_header("Sample conversions", 50));
275      for i in 0..show_count {
276         let old = commits[i].message.lines().next().unwrap_or("");
277         let new = new_messages[i].lines().next().unwrap_or("");
278
279         println!("[{}] {}", i + 1, style::dim(&commits[i].hash[..8]));
280         println!("  {} {}", style::error("-"), style::dim(&TruncStr(old, 70).to_string()));
281         println!("  {} {}", style::success("+"), TruncStr(new, 70));
282         println!();
283      }
284   }
285}
286
287struct TruncStr<'a>(&'a str, usize);
288
289impl fmt::Display for TruncStr<'_> {
290   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291      if self.0.len() <= self.1 {
292         f.write_str(self.0)
293      } else {
294         let n = self.0.floor_char_boundary(self.1);
295         f.write_str(&self.0[..n])?;
296         f.write_str("...")
297      }
298   }
299}