1use anyhow::{Context, Result};
4use chrono::{DateTime, FixedOffset};
5use git2::{Commit, Repository};
6use serde::{Deserialize, Serialize};
7use std::fs;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CommitInfo {
12 pub hash: String,
14 pub author: String,
16 pub date: DateTime<FixedOffset>,
18 pub original_message: String,
20 pub in_main_branches: Vec<String>,
22 pub analysis: CommitAnalysis,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CommitAnalysis {
29 pub detected_type: String,
31 pub detected_scope: String,
33 pub proposed_message: String,
35 pub file_changes: FileChanges,
37 pub diff_summary: String,
39 pub diff_file: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CommitAnalysisForAI {
46 pub detected_type: String,
48 pub detected_scope: String,
50 pub proposed_message: String,
52 pub file_changes: FileChanges,
54 pub diff_summary: String,
56 pub diff_file: String,
58 pub diff_content: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CommitInfoForAI {
65 pub hash: String,
67 pub author: String,
69 pub date: DateTime<FixedOffset>,
71 pub original_message: String,
73 pub in_main_branches: Vec<String>,
75 pub analysis: CommitAnalysisForAI,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FileChanges {
82 pub total_files: usize,
84 pub files_added: usize,
86 pub files_deleted: usize,
88 pub file_list: Vec<FileChange>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FileChange {
95 pub status: String,
97 pub file: String,
99}
100
101impl CommitInfo {
102 pub fn from_git_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
104 let hash = commit.id().to_string();
105
106 let author = format!(
107 "{} <{}>",
108 commit.author().name().unwrap_or("Unknown"),
109 commit.author().email().unwrap_or("unknown@example.com")
110 );
111
112 let timestamp = commit.author().when();
113 let date = DateTime::from_timestamp(timestamp.seconds(), 0)
114 .context("Invalid commit timestamp")?
115 .with_timezone(
116 &FixedOffset::east_opt(timestamp.offset_minutes() * 60)
117 .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()),
118 );
119
120 let original_message = commit.message().unwrap_or("").to_string();
121
122 let in_main_branches = Vec::new();
124
125 let analysis = CommitAnalysis::analyze_commit(repo, commit)?;
127
128 Ok(Self {
129 hash,
130 author,
131 date,
132 original_message,
133 in_main_branches,
134 analysis,
135 })
136 }
137}
138
139impl CommitAnalysis {
140 pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
142 let file_changes = Self::analyze_file_changes(repo, commit)?;
144
145 let detected_type = Self::detect_commit_type(commit, &file_changes);
147
148 let detected_scope = Self::detect_scope(&file_changes);
150
151 let proposed_message =
153 Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
154
155 let diff_summary = Self::get_diff_summary(repo, commit)?;
157
158 let diff_file = Self::write_diff_to_file(repo, commit)?;
160
161 Ok(Self {
162 detected_type,
163 detected_scope,
164 proposed_message,
165 file_changes,
166 diff_summary,
167 diff_file,
168 })
169 }
170
171 fn analyze_file_changes(repo: &Repository, commit: &Commit) -> Result<FileChanges> {
173 let mut file_list = Vec::new();
174 let mut files_added = 0;
175 let mut files_deleted = 0;
176
177 let commit_tree = commit.tree().context("Failed to get commit tree")?;
179
180 let parent_tree = if commit.parent_count() > 0 {
182 Some(
183 commit
184 .parent(0)
185 .context("Failed to get parent commit")?
186 .tree()
187 .context("Failed to get parent tree")?,
188 )
189 } else {
190 None
191 };
192
193 let diff = if let Some(parent_tree) = parent_tree {
195 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
196 .context("Failed to create diff")?
197 } else {
198 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
200 .context("Failed to create diff for initial commit")?
201 };
202
203 diff.foreach(
205 &mut |delta, _progress| {
206 let status = match delta.status() {
207 git2::Delta::Added => {
208 files_added += 1;
209 "A"
210 }
211 git2::Delta::Deleted => {
212 files_deleted += 1;
213 "D"
214 }
215 git2::Delta::Modified => "M",
216 git2::Delta::Renamed => "R",
217 git2::Delta::Copied => "C",
218 git2::Delta::Typechange => "T",
219 _ => "?",
220 };
221
222 if let Some(path) = delta.new_file().path() {
223 if let Some(path_str) = path.to_str() {
224 file_list.push(FileChange {
225 status: status.to_string(),
226 file: path_str.to_string(),
227 });
228 }
229 }
230
231 true
232 },
233 None,
234 None,
235 None,
236 )
237 .context("Failed to process diff")?;
238
239 let total_files = file_list.len();
240
241 Ok(FileChanges {
242 total_files,
243 files_added,
244 files_deleted,
245 file_list,
246 })
247 }
248
249 fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
251 let message = commit.message().unwrap_or("");
252
253 if let Some(existing_type) = Self::extract_conventional_type(message) {
255 return existing_type;
256 }
257
258 let files: Vec<&str> = file_changes
260 .file_list
261 .iter()
262 .map(|f| f.file.as_str())
263 .collect();
264
265 if files
267 .iter()
268 .any(|f| f.contains("test") || f.contains("spec"))
269 {
270 "test".to_string()
271 } else if files
272 .iter()
273 .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
274 {
275 "docs".to_string()
276 } else if files
277 .iter()
278 .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
279 {
280 if file_changes.files_added > 0 {
281 "feat".to_string()
282 } else {
283 "chore".to_string()
284 }
285 } else if file_changes.files_added > 0
286 && files
287 .iter()
288 .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
289 {
290 "feat".to_string()
291 } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
292 "fix".to_string()
293 } else if file_changes.files_deleted > file_changes.files_added {
294 "refactor".to_string()
295 } else {
296 "chore".to_string()
297 }
298 }
299
300 fn extract_conventional_type(message: &str) -> Option<String> {
302 let first_line = message.lines().next().unwrap_or("");
303 if let Some(colon_pos) = first_line.find(':') {
304 let prefix = &first_line[..colon_pos];
305 if let Some(paren_pos) = prefix.find('(') {
306 let type_part = &prefix[..paren_pos];
307 if Self::is_valid_conventional_type(type_part) {
308 return Some(type_part.to_string());
309 }
310 } else if Self::is_valid_conventional_type(prefix) {
311 return Some(prefix.to_string());
312 }
313 }
314 None
315 }
316
317 fn is_valid_conventional_type(s: &str) -> bool {
319 matches!(
320 s,
321 "feat"
322 | "fix"
323 | "docs"
324 | "style"
325 | "refactor"
326 | "test"
327 | "chore"
328 | "build"
329 | "ci"
330 | "perf"
331 )
332 }
333
334 fn detect_scope(file_changes: &FileChanges) -> String {
336 let files: Vec<&str> = file_changes
337 .file_list
338 .iter()
339 .map(|f| f.file.as_str())
340 .collect();
341
342 if files.iter().any(|f| f.starts_with("src/cli/")) {
344 "cli".to_string()
345 } else if files.iter().any(|f| f.starts_with("src/git/")) {
346 "git".to_string()
347 } else if files.iter().any(|f| f.starts_with("src/data/")) {
348 "data".to_string()
349 } else if files.iter().any(|f| f.starts_with("tests/")) {
350 "test".to_string()
351 } else if files.iter().any(|f| f.starts_with("docs/")) {
352 "docs".to_string()
353 } else if files
354 .iter()
355 .any(|f| f.contains("Cargo.toml") || f.contains("deny.toml"))
356 {
357 "deps".to_string()
358 } else {
359 "".to_string()
360 }
361 }
362
363 fn generate_proposed_message(
365 commit: &Commit,
366 commit_type: &str,
367 scope: &str,
368 file_changes: &FileChanges,
369 ) -> String {
370 let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
371
372 if Self::extract_conventional_type(current_message).is_some() {
374 return current_message.to_string();
375 }
376
377 let description =
379 if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
380 current_message.to_string()
381 } else {
382 Self::generate_description(commit_type, file_changes)
383 };
384
385 if scope.is_empty() {
387 format!("{}: {}", commit_type, description)
388 } else {
389 format!("{}({}): {}", commit_type, scope, description)
390 }
391 }
392
393 fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
395 match commit_type {
396 "feat" => {
397 if file_changes.total_files == 1 {
398 format!("add {}", file_changes.file_list[0].file)
399 } else {
400 format!("add {} new features", file_changes.total_files)
401 }
402 }
403 "fix" => "resolve issues".to_string(),
404 "docs" => "update documentation".to_string(),
405 "test" => "add tests".to_string(),
406 "refactor" => "improve code structure".to_string(),
407 "chore" => "update project files".to_string(),
408 _ => "update project".to_string(),
409 }
410 }
411
412 fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
414 let commit_tree = commit.tree().context("Failed to get commit tree")?;
415
416 let parent_tree = if commit.parent_count() > 0 {
417 Some(
418 commit
419 .parent(0)
420 .context("Failed to get parent commit")?
421 .tree()
422 .context("Failed to get parent tree")?,
423 )
424 } else {
425 None
426 };
427
428 let diff = if let Some(parent_tree) = parent_tree {
429 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
430 .context("Failed to create diff")?
431 } else {
432 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
433 .context("Failed to create diff for initial commit")?
434 };
435
436 let stats = diff.stats().context("Failed to get diff stats")?;
437
438 let mut summary = String::new();
439 for i in 0..stats.files_changed() {
440 if let Some(path) = diff
441 .get_delta(i)
442 .and_then(|d| d.new_file().path())
443 .and_then(|p| p.to_str())
444 {
445 let insertions = stats.insertions();
446 let deletions = stats.deletions();
447 summary.push_str(&format!(
448 " {} | {} +{} -{}\n",
449 path,
450 insertions + deletions,
451 insertions,
452 deletions
453 ));
454 }
455 }
456
457 Ok(summary)
458 }
459
460 fn write_diff_to_file(repo: &Repository, commit: &Commit) -> Result<String> {
462 let ai_scratch_path = crate::utils::ai_scratch::get_ai_scratch_dir()
464 .context("Failed to determine AI scratch directory")?;
465
466 let diffs_dir = ai_scratch_path.join("diffs");
468 fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
469
470 let commit_hash = commit.id().to_string();
472 let diff_filename = format!("{}.diff", commit_hash);
473 let diff_path = diffs_dir.join(&diff_filename);
474
475 let commit_tree = commit.tree().context("Failed to get commit tree")?;
476
477 let parent_tree = if commit.parent_count() > 0 {
478 Some(
479 commit
480 .parent(0)
481 .context("Failed to get parent commit")?
482 .tree()
483 .context("Failed to get parent tree")?,
484 )
485 } else {
486 None
487 };
488
489 let diff = if let Some(parent_tree) = parent_tree {
490 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
491 .context("Failed to create diff")?
492 } else {
493 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
494 .context("Failed to create diff for initial commit")?
495 };
496
497 let mut diff_content = String::new();
498
499 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
500 let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
501 let prefix = match line.origin() {
502 '+' => "+",
503 '-' => "-",
504 ' ' => " ",
505 '@' => "@",
506 'H' => "", 'F' => "", _ => "",
509 };
510 diff_content.push_str(&format!("{}{}", prefix, content));
511 true
512 })
513 .context("Failed to format diff")?;
514
515 if !diff_content.ends_with('\n') {
517 diff_content.push('\n');
518 }
519
520 fs::write(&diff_path, diff_content).context("Failed to write diff file")?;
522
523 Ok(diff_path.to_string_lossy().to_string())
525 }
526}
527
528impl CommitInfoForAI {
529 pub fn from_commit_info(commit_info: CommitInfo) -> Result<Self> {
531 let analysis = CommitAnalysisForAI::from_commit_analysis(commit_info.analysis)?;
532
533 Ok(Self {
534 hash: commit_info.hash,
535 author: commit_info.author,
536 date: commit_info.date,
537 original_message: commit_info.original_message,
538 in_main_branches: commit_info.in_main_branches,
539 analysis,
540 })
541 }
542}
543
544impl CommitAnalysisForAI {
545 pub fn from_commit_analysis(analysis: CommitAnalysis) -> Result<Self> {
547 let diff_content = fs::read_to_string(&analysis.diff_file)
549 .with_context(|| format!("Failed to read diff file: {}", analysis.diff_file))?;
550
551 Ok(Self {
552 detected_type: analysis.detected_type,
553 detected_scope: analysis.detected_scope,
554 proposed_message: analysis.proposed_message,
555 file_changes: analysis.file_changes,
556 diff_summary: analysis.diff_summary,
557 diff_file: analysis.diff_file,
558 diff_content,
559 })
560 }
561}