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
23pub fn run_rewrite_mode(args: &Args, config: &CommitConfig) -> Result<()> {
25 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 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 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 if args.rewrite_dry_run && args.rewrite_preview.is_some() {
60 print_preview_list(&commits);
61 return Ok(());
62 }
63
64 println!(
66 "{} Converting to conventional commits (parallel={})...\n",
67 style::info("🤖"),
68 style::bold(&args.rewrite_parallel.to_string())
69 );
70
71 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 print_conversion_results(&commits, &new_messages);
79
80 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 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 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
111fn 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 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 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
174fn 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 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 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 let (scope_candidates_str, _) =
196 extract_scope_candidates(&Mode::Commit, Some(&commit.hash), dir, config)?;
197
198 let ctx = AnalysisContext {
200 user_context: None, recent_commits: None, common_scopes: None, project_context: None, };
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 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, config,
223 )?;
224
225 let mut commit_msg = ConventionalCommit {
229 commit_type: analysis.commit_type,
230 scope: analysis.scope,
231 summary,
232 body: body_texts,
233 footers: vec![], };
235
236 post_process_commit_message(&mut commit_msg, config);
238 validate_commit_message(&commit_msg, config)?;
239
240 Ok(format_commit_message(&commit_msg))
242}
243
244fn 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
270fn 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 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}