1use anyhow::{Context, Result};
4use chrono::{DateTime, FixedOffset};
5use git2::{Commit, Repository};
6use globset::Glob;
7use serde::{Deserialize, Serialize};
8use std::fs;
9
10use crate::data::context::ScopeDefinition;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct CommitInfo {
15 pub hash: String,
17 pub author: String,
19 pub date: DateTime<FixedOffset>,
21 pub original_message: String,
23 pub in_main_branches: Vec<String>,
25 pub analysis: CommitAnalysis,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CommitAnalysis {
32 pub detected_type: String,
34 pub detected_scope: String,
36 pub proposed_message: String,
38 pub file_changes: FileChanges,
40 pub diff_summary: String,
42 pub diff_file: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CommitAnalysisForAI {
49 pub detected_type: String,
51 pub detected_scope: String,
53 pub proposed_message: String,
55 pub file_changes: FileChanges,
57 pub diff_summary: String,
59 pub diff_file: String,
61 pub diff_content: String,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CommitInfoForAI {
68 pub hash: String,
70 pub author: String,
72 pub date: DateTime<FixedOffset>,
74 pub original_message: String,
76 pub in_main_branches: Vec<String>,
78 pub analysis: CommitAnalysisForAI,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct FileChanges {
85 pub total_files: usize,
87 pub files_added: usize,
89 pub files_deleted: usize,
91 pub file_list: Vec<FileChange>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct FileChange {
98 pub status: String,
100 pub file: String,
102}
103
104impl CommitInfo {
105 pub fn from_git_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
107 let hash = commit.id().to_string();
108
109 let author = format!(
110 "{} <{}>",
111 commit.author().name().unwrap_or("Unknown"),
112 commit.author().email().unwrap_or("unknown@example.com")
113 );
114
115 let timestamp = commit.author().when();
116 let date = DateTime::from_timestamp(timestamp.seconds(), 0)
117 .context("Invalid commit timestamp")?
118 .with_timezone(
119 &FixedOffset::east_opt(timestamp.offset_minutes() * 60)
120 .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()),
121 );
122
123 let original_message = commit.message().unwrap_or("").to_string();
124
125 let in_main_branches = Vec::new();
127
128 let analysis = CommitAnalysis::analyze_commit(repo, commit)?;
130
131 Ok(Self {
132 hash,
133 author,
134 date,
135 original_message,
136 in_main_branches,
137 analysis,
138 })
139 }
140}
141
142impl CommitAnalysis {
143 pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
145 let file_changes = Self::analyze_file_changes(repo, commit)?;
147
148 let detected_type = Self::detect_commit_type(commit, &file_changes);
150
151 let detected_scope = Self::detect_scope(&file_changes);
153
154 let proposed_message =
156 Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
157
158 let diff_summary = Self::get_diff_summary(repo, commit)?;
160
161 let diff_file = Self::write_diff_to_file(repo, commit)?;
163
164 Ok(Self {
165 detected_type,
166 detected_scope,
167 proposed_message,
168 file_changes,
169 diff_summary,
170 diff_file,
171 })
172 }
173
174 fn analyze_file_changes(repo: &Repository, commit: &Commit) -> Result<FileChanges> {
176 let mut file_list = Vec::new();
177 let mut files_added = 0;
178 let mut files_deleted = 0;
179
180 let commit_tree = commit.tree().context("Failed to get commit tree")?;
182
183 let parent_tree = if commit.parent_count() > 0 {
185 Some(
186 commit
187 .parent(0)
188 .context("Failed to get parent commit")?
189 .tree()
190 .context("Failed to get parent tree")?,
191 )
192 } else {
193 None
194 };
195
196 let diff = if let Some(parent_tree) = parent_tree {
198 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
199 .context("Failed to create diff")?
200 } else {
201 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
203 .context("Failed to create diff for initial commit")?
204 };
205
206 diff.foreach(
208 &mut |delta, _progress| {
209 let status = match delta.status() {
210 git2::Delta::Added => {
211 files_added += 1;
212 "A"
213 }
214 git2::Delta::Deleted => {
215 files_deleted += 1;
216 "D"
217 }
218 git2::Delta::Modified => "M",
219 git2::Delta::Renamed => "R",
220 git2::Delta::Copied => "C",
221 git2::Delta::Typechange => "T",
222 _ => "?",
223 };
224
225 if let Some(path) = delta.new_file().path() {
226 if let Some(path_str) = path.to_str() {
227 file_list.push(FileChange {
228 status: status.to_string(),
229 file: path_str.to_string(),
230 });
231 }
232 }
233
234 true
235 },
236 None,
237 None,
238 None,
239 )
240 .context("Failed to process diff")?;
241
242 let total_files = file_list.len();
243
244 Ok(FileChanges {
245 total_files,
246 files_added,
247 files_deleted,
248 file_list,
249 })
250 }
251
252 fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
254 let message = commit.message().unwrap_or("");
255
256 if let Some(existing_type) = Self::extract_conventional_type(message) {
258 return existing_type;
259 }
260
261 let files: Vec<&str> = file_changes
263 .file_list
264 .iter()
265 .map(|f| f.file.as_str())
266 .collect();
267
268 if files
270 .iter()
271 .any(|f| f.contains("test") || f.contains("spec"))
272 {
273 "test".to_string()
274 } else if files
275 .iter()
276 .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
277 {
278 "docs".to_string()
279 } else if files
280 .iter()
281 .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
282 {
283 if file_changes.files_added > 0 {
284 "feat".to_string()
285 } else {
286 "chore".to_string()
287 }
288 } else if file_changes.files_added > 0
289 && files
290 .iter()
291 .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
292 {
293 "feat".to_string()
294 } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
295 "fix".to_string()
296 } else if file_changes.files_deleted > file_changes.files_added {
297 "refactor".to_string()
298 } else {
299 "chore".to_string()
300 }
301 }
302
303 fn extract_conventional_type(message: &str) -> Option<String> {
305 let first_line = message.lines().next().unwrap_or("");
306 if let Some(colon_pos) = first_line.find(':') {
307 let prefix = &first_line[..colon_pos];
308 if let Some(paren_pos) = prefix.find('(') {
309 let type_part = &prefix[..paren_pos];
310 if Self::is_valid_conventional_type(type_part) {
311 return Some(type_part.to_string());
312 }
313 } else if Self::is_valid_conventional_type(prefix) {
314 return Some(prefix.to_string());
315 }
316 }
317 None
318 }
319
320 fn is_valid_conventional_type(s: &str) -> bool {
322 matches!(
323 s,
324 "feat"
325 | "fix"
326 | "docs"
327 | "style"
328 | "refactor"
329 | "test"
330 | "chore"
331 | "build"
332 | "ci"
333 | "perf"
334 )
335 }
336
337 fn detect_scope(file_changes: &FileChanges) -> String {
339 let files: Vec<&str> = file_changes
340 .file_list
341 .iter()
342 .map(|f| f.file.as_str())
343 .collect();
344
345 if files.iter().any(|f| f.starts_with("src/cli/")) {
347 "cli".to_string()
348 } else if files.iter().any(|f| f.starts_with("src/git/")) {
349 "git".to_string()
350 } else if files.iter().any(|f| f.starts_with("src/data/")) {
351 "data".to_string()
352 } else if files.iter().any(|f| f.starts_with("tests/")) {
353 "test".to_string()
354 } else if files.iter().any(|f| f.starts_with("docs/")) {
355 "docs".to_string()
356 } else if files
357 .iter()
358 .any(|f| f.contains("Cargo.toml") || f.contains("deny.toml"))
359 {
360 "deps".to_string()
361 } else {
362 "".to_string()
363 }
364 }
365
366 pub fn refine_scope(&mut self, scope_defs: &[ScopeDefinition]) {
373 if scope_defs.is_empty() {
374 return;
375 }
376 let files: Vec<&str> = self
377 .file_changes
378 .file_list
379 .iter()
380 .map(|f| f.file.as_str())
381 .collect();
382 if files.is_empty() {
383 return;
384 }
385
386 let mut matches: Vec<(&str, usize)> = Vec::new();
387 for scope_def in scope_defs {
388 if let Some(specificity) = Self::scope_matches_files(&files, &scope_def.file_patterns) {
389 matches.push((&scope_def.name, specificity));
390 }
391 }
392
393 if matches.is_empty() {
394 return;
395 }
396
397 let max_specificity = matches.iter().map(|(_, s)| *s).max().unwrap();
398 let best: Vec<&str> = matches
399 .into_iter()
400 .filter(|(_, s)| *s == max_specificity)
401 .map(|(name, _)| name)
402 .collect();
403
404 self.detected_scope = best.join(", ");
405 }
406
407 fn scope_matches_files(files: &[&str], patterns: &[String]) -> Option<usize> {
412 let mut positive = Vec::new();
413 let mut negative = Vec::new();
414 for pat in patterns {
415 if let Some(stripped) = pat.strip_prefix('!') {
416 negative.push(stripped);
417 } else {
418 positive.push(pat.as_str());
419 }
420 }
421
422 let neg_matchers: Vec<_> = negative
424 .iter()
425 .filter_map(|p| Glob::new(p).ok().map(|g| g.compile_matcher()))
426 .collect();
427
428 let mut max_specificity: Option<usize> = None;
429 for pat in &positive {
430 let glob = match Glob::new(pat) {
431 Ok(g) => g,
432 Err(_) => continue,
433 };
434 let matcher = glob.compile_matcher();
435 for file in files {
436 if matcher.is_match(file) && !neg_matchers.iter().any(|neg| neg.is_match(file)) {
437 let specificity = Self::count_specificity(pat);
438 max_specificity =
439 Some(max_specificity.map_or(specificity, |cur| cur.max(specificity)));
440 }
441 }
442 }
443 max_specificity
444 }
445
446 fn count_specificity(pattern: &str) -> usize {
453 pattern
454 .split('/')
455 .filter(|segment| !segment.contains('*') && !segment.contains('?'))
456 .count()
457 }
458
459 fn generate_proposed_message(
461 commit: &Commit,
462 commit_type: &str,
463 scope: &str,
464 file_changes: &FileChanges,
465 ) -> String {
466 let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
467
468 if Self::extract_conventional_type(current_message).is_some() {
470 return current_message.to_string();
471 }
472
473 let description =
475 if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
476 current_message.to_string()
477 } else {
478 Self::generate_description(commit_type, file_changes)
479 };
480
481 if scope.is_empty() {
483 format!("{}: {}", commit_type, description)
484 } else {
485 format!("{}({}): {}", commit_type, scope, description)
486 }
487 }
488
489 fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
491 match commit_type {
492 "feat" => {
493 if file_changes.total_files == 1 {
494 format!("add {}", file_changes.file_list[0].file)
495 } else {
496 format!("add {} new features", file_changes.total_files)
497 }
498 }
499 "fix" => "resolve issues".to_string(),
500 "docs" => "update documentation".to_string(),
501 "test" => "add tests".to_string(),
502 "refactor" => "improve code structure".to_string(),
503 "chore" => "update project files".to_string(),
504 _ => "update project".to_string(),
505 }
506 }
507
508 fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
510 let commit_tree = commit.tree().context("Failed to get commit tree")?;
511
512 let parent_tree = if commit.parent_count() > 0 {
513 Some(
514 commit
515 .parent(0)
516 .context("Failed to get parent commit")?
517 .tree()
518 .context("Failed to get parent tree")?,
519 )
520 } else {
521 None
522 };
523
524 let diff = if let Some(parent_tree) = parent_tree {
525 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
526 .context("Failed to create diff")?
527 } else {
528 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
529 .context("Failed to create diff for initial commit")?
530 };
531
532 let stats = diff.stats().context("Failed to get diff stats")?;
533
534 let mut summary = String::new();
535 for i in 0..stats.files_changed() {
536 if let Some(path) = diff
537 .get_delta(i)
538 .and_then(|d| d.new_file().path())
539 .and_then(|p| p.to_str())
540 {
541 let insertions = stats.insertions();
542 let deletions = stats.deletions();
543 summary.push_str(&format!(
544 " {} | {} +{} -{}\n",
545 path,
546 insertions + deletions,
547 insertions,
548 deletions
549 ));
550 }
551 }
552
553 Ok(summary)
554 }
555
556 fn write_diff_to_file(repo: &Repository, commit: &Commit) -> Result<String> {
558 let ai_scratch_path = crate::utils::ai_scratch::get_ai_scratch_dir()
560 .context("Failed to determine AI scratch directory")?;
561
562 let diffs_dir = ai_scratch_path.join("diffs");
564 fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
565
566 let commit_hash = commit.id().to_string();
568 let diff_filename = format!("{}.diff", commit_hash);
569 let diff_path = diffs_dir.join(&diff_filename);
570
571 let commit_tree = commit.tree().context("Failed to get commit tree")?;
572
573 let parent_tree = if commit.parent_count() > 0 {
574 Some(
575 commit
576 .parent(0)
577 .context("Failed to get parent commit")?
578 .tree()
579 .context("Failed to get parent tree")?,
580 )
581 } else {
582 None
583 };
584
585 let diff = if let Some(parent_tree) = parent_tree {
586 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
587 .context("Failed to create diff")?
588 } else {
589 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
590 .context("Failed to create diff for initial commit")?
591 };
592
593 let mut diff_content = String::new();
594
595 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
596 let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
597 let prefix = match line.origin() {
598 '+' => "+",
599 '-' => "-",
600 ' ' => " ",
601 '@' => "@",
602 'H' => "", 'F' => "", _ => "",
605 };
606 diff_content.push_str(&format!("{}{}", prefix, content));
607 true
608 })
609 .context("Failed to format diff")?;
610
611 if !diff_content.ends_with('\n') {
613 diff_content.push('\n');
614 }
615
616 fs::write(&diff_path, diff_content).context("Failed to write diff file")?;
618
619 Ok(diff_path.to_string_lossy().to_string())
621 }
622}
623
624impl CommitInfoForAI {
625 pub fn from_commit_info(commit_info: CommitInfo) -> Result<Self> {
627 let analysis = CommitAnalysisForAI::from_commit_analysis(commit_info.analysis)?;
628
629 Ok(Self {
630 hash: commit_info.hash,
631 author: commit_info.author,
632 date: commit_info.date,
633 original_message: commit_info.original_message,
634 in_main_branches: commit_info.in_main_branches,
635 analysis,
636 })
637 }
638}
639
640impl CommitAnalysisForAI {
641 pub fn from_commit_analysis(analysis: CommitAnalysis) -> Result<Self> {
643 let diff_content = fs::read_to_string(&analysis.diff_file)
645 .with_context(|| format!("Failed to read diff file: {}", analysis.diff_file))?;
646
647 Ok(Self {
648 detected_type: analysis.detected_type,
649 detected_scope: analysis.detected_scope,
650 proposed_message: analysis.proposed_message,
651 file_changes: analysis.file_changes,
652 diff_summary: analysis.diff_summary,
653 diff_file: analysis.diff_file,
654 diff_content,
655 })
656 }
657}