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 FileChanges {
46 pub total_files: usize,
48 pub files_added: usize,
50 pub files_deleted: usize,
52 pub file_list: Vec<FileChange>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FileChange {
59 pub status: String,
61 pub file: String,
63}
64
65impl CommitInfo {
66 pub fn from_git_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
68 let hash = commit.id().to_string();
69
70 let author = format!(
71 "{} <{}>",
72 commit.author().name().unwrap_or("Unknown"),
73 commit.author().email().unwrap_or("unknown@example.com")
74 );
75
76 let timestamp = commit.author().when();
77 let date = DateTime::from_timestamp(timestamp.seconds(), 0)
78 .context("Invalid commit timestamp")?
79 .with_timezone(
80 &FixedOffset::east_opt(timestamp.offset_minutes() * 60)
81 .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()),
82 );
83
84 let original_message = commit.message().unwrap_or("").to_string();
85
86 let in_main_branches = Vec::new();
88
89 let analysis = CommitAnalysis::analyze_commit(repo, commit)?;
91
92 Ok(Self {
93 hash,
94 author,
95 date,
96 original_message,
97 in_main_branches,
98 analysis,
99 })
100 }
101}
102
103impl CommitAnalysis {
104 pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
106 let file_changes = Self::analyze_file_changes(repo, commit)?;
108
109 let detected_type = Self::detect_commit_type(commit, &file_changes);
111
112 let detected_scope = Self::detect_scope(&file_changes);
114
115 let proposed_message =
117 Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
118
119 let diff_summary = Self::get_diff_summary(repo, commit)?;
121
122 let diff_file = Self::write_diff_to_file(repo, commit)?;
124
125 Ok(Self {
126 detected_type,
127 detected_scope,
128 proposed_message,
129 file_changes,
130 diff_summary,
131 diff_file,
132 })
133 }
134
135 fn analyze_file_changes(repo: &Repository, commit: &Commit) -> Result<FileChanges> {
137 let mut file_list = Vec::new();
138 let mut files_added = 0;
139 let mut files_deleted = 0;
140
141 let commit_tree = commit.tree().context("Failed to get commit tree")?;
143
144 let parent_tree = if commit.parent_count() > 0 {
146 Some(
147 commit
148 .parent(0)
149 .context("Failed to get parent commit")?
150 .tree()
151 .context("Failed to get parent tree")?,
152 )
153 } else {
154 None
155 };
156
157 let diff = if let Some(parent_tree) = parent_tree {
159 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
160 .context("Failed to create diff")?
161 } else {
162 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
164 .context("Failed to create diff for initial commit")?
165 };
166
167 diff.foreach(
169 &mut |delta, _progress| {
170 let status = match delta.status() {
171 git2::Delta::Added => {
172 files_added += 1;
173 "A"
174 }
175 git2::Delta::Deleted => {
176 files_deleted += 1;
177 "D"
178 }
179 git2::Delta::Modified => "M",
180 git2::Delta::Renamed => "R",
181 git2::Delta::Copied => "C",
182 git2::Delta::Typechange => "T",
183 _ => "?",
184 };
185
186 if let Some(path) = delta.new_file().path() {
187 if let Some(path_str) = path.to_str() {
188 file_list.push(FileChange {
189 status: status.to_string(),
190 file: path_str.to_string(),
191 });
192 }
193 }
194
195 true
196 },
197 None,
198 None,
199 None,
200 )
201 .context("Failed to process diff")?;
202
203 let total_files = file_list.len();
204
205 Ok(FileChanges {
206 total_files,
207 files_added,
208 files_deleted,
209 file_list,
210 })
211 }
212
213 fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
215 let message = commit.message().unwrap_or("");
216
217 if let Some(existing_type) = Self::extract_conventional_type(message) {
219 return existing_type;
220 }
221
222 let files: Vec<&str> = file_changes
224 .file_list
225 .iter()
226 .map(|f| f.file.as_str())
227 .collect();
228
229 if files
231 .iter()
232 .any(|f| f.contains("test") || f.contains("spec"))
233 {
234 "test".to_string()
235 } else if files
236 .iter()
237 .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
238 {
239 "docs".to_string()
240 } else if files
241 .iter()
242 .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
243 {
244 if file_changes.files_added > 0 {
245 "feat".to_string()
246 } else {
247 "chore".to_string()
248 }
249 } else if file_changes.files_added > 0
250 && files
251 .iter()
252 .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
253 {
254 "feat".to_string()
255 } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
256 "fix".to_string()
257 } else if file_changes.files_deleted > file_changes.files_added {
258 "refactor".to_string()
259 } else {
260 "chore".to_string()
261 }
262 }
263
264 fn extract_conventional_type(message: &str) -> Option<String> {
266 let first_line = message.lines().next().unwrap_or("");
267 if let Some(colon_pos) = first_line.find(':') {
268 let prefix = &first_line[..colon_pos];
269 if let Some(paren_pos) = prefix.find('(') {
270 let type_part = &prefix[..paren_pos];
271 if Self::is_valid_conventional_type(type_part) {
272 return Some(type_part.to_string());
273 }
274 } else if Self::is_valid_conventional_type(prefix) {
275 return Some(prefix.to_string());
276 }
277 }
278 None
279 }
280
281 fn is_valid_conventional_type(s: &str) -> bool {
283 matches!(
284 s,
285 "feat"
286 | "fix"
287 | "docs"
288 | "style"
289 | "refactor"
290 | "test"
291 | "chore"
292 | "build"
293 | "ci"
294 | "perf"
295 )
296 }
297
298 fn detect_scope(file_changes: &FileChanges) -> String {
300 let files: Vec<&str> = file_changes
301 .file_list
302 .iter()
303 .map(|f| f.file.as_str())
304 .collect();
305
306 if files.iter().any(|f| f.starts_with("src/cli/")) {
308 "cli".to_string()
309 } else if files.iter().any(|f| f.starts_with("src/git/")) {
310 "git".to_string()
311 } else if files.iter().any(|f| f.starts_with("src/data/")) {
312 "data".to_string()
313 } else if files.iter().any(|f| f.starts_with("tests/")) {
314 "test".to_string()
315 } else if files.iter().any(|f| f.starts_with("docs/")) {
316 "docs".to_string()
317 } else if files
318 .iter()
319 .any(|f| f.contains("Cargo.toml") || f.contains("deny.toml"))
320 {
321 "deps".to_string()
322 } else {
323 "".to_string()
324 }
325 }
326
327 fn generate_proposed_message(
329 commit: &Commit,
330 commit_type: &str,
331 scope: &str,
332 file_changes: &FileChanges,
333 ) -> String {
334 let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
335
336 if Self::extract_conventional_type(current_message).is_some() {
338 return current_message.to_string();
339 }
340
341 let description =
343 if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
344 current_message.to_string()
345 } else {
346 Self::generate_description(commit_type, file_changes)
347 };
348
349 if scope.is_empty() {
351 format!("{}: {}", commit_type, description)
352 } else {
353 format!("{}({}): {}", commit_type, scope, description)
354 }
355 }
356
357 fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
359 match commit_type {
360 "feat" => {
361 if file_changes.total_files == 1 {
362 format!("add {}", file_changes.file_list[0].file)
363 } else {
364 format!("add {} new features", file_changes.total_files)
365 }
366 }
367 "fix" => "resolve issues".to_string(),
368 "docs" => "update documentation".to_string(),
369 "test" => "add tests".to_string(),
370 "refactor" => "improve code structure".to_string(),
371 "chore" => "update project files".to_string(),
372 _ => "update project".to_string(),
373 }
374 }
375
376 fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
378 let commit_tree = commit.tree().context("Failed to get commit tree")?;
379
380 let parent_tree = if commit.parent_count() > 0 {
381 Some(
382 commit
383 .parent(0)
384 .context("Failed to get parent commit")?
385 .tree()
386 .context("Failed to get parent tree")?,
387 )
388 } else {
389 None
390 };
391
392 let diff = if let Some(parent_tree) = parent_tree {
393 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
394 .context("Failed to create diff")?
395 } else {
396 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
397 .context("Failed to create diff for initial commit")?
398 };
399
400 let stats = diff.stats().context("Failed to get diff stats")?;
401
402 let mut summary = String::new();
403 for i in 0..stats.files_changed() {
404 if let Some(path) = diff
405 .get_delta(i)
406 .and_then(|d| d.new_file().path())
407 .and_then(|p| p.to_str())
408 {
409 let insertions = stats.insertions();
410 let deletions = stats.deletions();
411 summary.push_str(&format!(
412 " {} | {} +{} -{}\n",
413 path,
414 insertions + deletions,
415 insertions,
416 deletions
417 ));
418 }
419 }
420
421 Ok(summary)
422 }
423
424 fn write_diff_to_file(repo: &Repository, commit: &Commit) -> Result<String> {
426 let ai_scratch_path = crate::utils::ai_scratch::get_ai_scratch_dir()
428 .context("Failed to determine AI scratch directory")?;
429
430 let diffs_dir = ai_scratch_path.join("diffs");
432 fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
433
434 let commit_hash = commit.id().to_string();
436 let diff_filename = format!("{}.diff", commit_hash);
437 let diff_path = diffs_dir.join(&diff_filename);
438
439 let commit_tree = commit.tree().context("Failed to get commit tree")?;
440
441 let parent_tree = if commit.parent_count() > 0 {
442 Some(
443 commit
444 .parent(0)
445 .context("Failed to get parent commit")?
446 .tree()
447 .context("Failed to get parent tree")?,
448 )
449 } else {
450 None
451 };
452
453 let diff = if let Some(parent_tree) = parent_tree {
454 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
455 .context("Failed to create diff")?
456 } else {
457 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
458 .context("Failed to create diff for initial commit")?
459 };
460
461 let mut diff_content = String::new();
462
463 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
464 let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
465 let prefix = match line.origin() {
466 '+' => "+",
467 '-' => "-",
468 ' ' => " ",
469 '@' => "@",
470 'H' => "", 'F' => "", _ => "",
473 };
474 diff_content.push_str(&format!("{}{}", prefix, content));
475 true
476 })
477 .context("Failed to format diff")?;
478
479 if !diff_content.ends_with('\n') {
481 diff_content.push('\n');
482 }
483
484 fs::write(&diff_path, diff_content).context("Failed to write diff file")?;
486
487 Ok(diff_path.to_string_lossy().to_string())
489 }
490}