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
22pub async fn run_rewrite_mode(args: &Args, config: &CommitConfig) -> Result<()> {
24 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 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 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 if args.rewrite_dry_run && args.rewrite_preview.is_some() {
59 print_preview_list(&commits);
60 return Ok(());
61 }
62
63 println!(
65 "{} Converting to conventional commits (parallel={})...\n",
66 style::info("🤖"),
67 style::bold(&args.rewrite_parallel.to_string())
68 );
69
70 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 print_conversion_results(&commits, &new_messages);
78
79 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 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 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
110async 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
164async 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 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 let (scope_candidates_str, _) =
181 extract_scope_candidates(&Mode::Commit, Some(&commit.hash), dir, config)?;
182 let ctx = AnalysisContext {
183 user_context: None, recent_commits: None, common_scopes: None, project_context: None, 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 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, config,
209 None,
210 None,
211 )
212 .await?;
213 let mut commit_msg = ConventionalCommit {
217 commit_type: analysis.commit_type,
218 scope: analysis.scope,
219 summary,
220 body: body_texts,
221 footers: vec![], };
223
224 post_process_commit_message(&mut commit_msg, config);
226 validate_commit_message(&commit_msg, config)?;
227
228 Ok(format_commit_message(&commit_msg))
230}
231
232fn 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
258fn 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 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}