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#[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 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).await?;
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
111#[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#[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 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 let (scope_candidates_str, _) =
184 extract_scope_candidates(&Mode::Commit, Some(&commit.hash), dir, config)?;
185 let ctx = AnalysisContext {
186 user_context: None, recent_commits: None, common_scopes: None, project_context: None, 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 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, config,
212 None,
213 None,
214 )
215 .await?;
216 let mut commit_msg = ConventionalCommit {
220 commit_type: analysis.commit_type,
221 scope: analysis.scope,
222 summary,
223 body: body_texts,
224 footers: vec![], };
226
227 post_process_commit_message(&mut commit_msg, config);
229 validate_commit_message(&commit_msg, config)?;
230
231 Ok(format_commit_message(&commit_msg))
233}
234
235#[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#[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 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}