omni_dev/git/
commit.rs

1//! Git commit operations and analysis
2
3use anyhow::{Context, Result};
4use chrono::{DateTime, FixedOffset};
5use git2::{Commit, Repository};
6use serde::{Deserialize, Serialize};
7use std::fs;
8
9/// Commit information structure
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CommitInfo {
12    /// Full SHA-1 hash of the commit
13    pub hash: String,
14    /// Commit author name and email address
15    pub author: String,
16    /// Commit date in ISO format with timezone
17    pub date: DateTime<FixedOffset>,
18    /// The original commit message as written by the author
19    pub original_message: String,
20    /// Array of remote main branches that contain this commit
21    pub in_main_branches: Vec<String>,
22    /// Automated analysis of the commit including type detection and proposed message
23    pub analysis: CommitAnalysis,
24}
25
26/// Commit analysis information
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CommitAnalysis {
29    /// Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.)
30    pub detected_type: String,
31    /// Automatically detected scope based on file paths (cli, git, data, etc.)
32    pub detected_scope: String,
33    /// AI-generated conventional commit message based on file changes
34    pub proposed_message: String,
35    /// Detailed statistics about file changes in this commit
36    pub file_changes: FileChanges,
37    /// Git diff --stat output showing lines changed per file
38    pub diff_summary: String,
39    /// Path to diff file showing line-by-line changes
40    pub diff_file: String,
41}
42
43/// Enhanced commit analysis for AI processing with full diff content
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CommitAnalysisForAI {
46    /// Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.)
47    pub detected_type: String,
48    /// Automatically detected scope based on file paths (cli, git, data, etc.)
49    pub detected_scope: String,
50    /// AI-generated conventional commit message based on file changes
51    pub proposed_message: String,
52    /// Detailed statistics about file changes in this commit
53    pub file_changes: FileChanges,
54    /// Git diff --stat output showing lines changed per file
55    pub diff_summary: String,
56    /// Path to diff file showing line-by-line changes
57    pub diff_file: String,
58    /// Full diff content for AI analysis
59    pub diff_content: String,
60}
61
62/// Commit information with enhanced analysis for AI processing
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CommitInfoForAI {
65    /// Full SHA-1 hash of the commit
66    pub hash: String,
67    /// Commit author name and email address
68    pub author: String,
69    /// Commit date in ISO format with timezone
70    pub date: DateTime<FixedOffset>,
71    /// The original commit message as written by the author
72    pub original_message: String,
73    /// Array of remote main branches that contain this commit
74    pub in_main_branches: Vec<String>,
75    /// Enhanced automated analysis of the commit including diff content
76    pub analysis: CommitAnalysisForAI,
77}
78
79/// File changes statistics
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FileChanges {
82    /// Total number of files modified in this commit
83    pub total_files: usize,
84    /// Number of new files added in this commit
85    pub files_added: usize,
86    /// Number of files deleted in this commit
87    pub files_deleted: usize,
88    /// Array of files changed with their git status (M=modified, A=added, D=deleted)
89    pub file_list: Vec<FileChange>,
90}
91
92/// Individual file change
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FileChange {
95    /// Git status code (A=added, M=modified, D=deleted, R=renamed)
96    pub status: String,
97    /// Path to the file relative to repository root
98    pub file: String,
99}
100
101impl CommitInfo {
102    /// Create CommitInfo from git2::Commit
103    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        // TODO: Implement main branch detection
123        let in_main_branches = Vec::new();
124
125        // TODO: Implement commit analysis
126        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    /// Analyze a commit and generate analysis information
141    pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
142        // Get file changes
143        let file_changes = Self::analyze_file_changes(repo, commit)?;
144
145        // Detect conventional commit type based on files and message
146        let detected_type = Self::detect_commit_type(commit, &file_changes);
147
148        // Detect scope based on file paths
149        let detected_scope = Self::detect_scope(&file_changes);
150
151        // Generate proposed conventional commit message
152        let proposed_message =
153            Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
154
155        // Get diff summary
156        let diff_summary = Self::get_diff_summary(repo, commit)?;
157
158        // Write diff to file and get path
159        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    /// Analyze file changes in the commit
172    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        // Get the tree for this commit
178        let commit_tree = commit.tree().context("Failed to get commit tree")?;
179
180        // Get parent tree if available
181        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        // Create diff between parent and commit
194        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            // Initial commit - diff against empty tree
199            repo.diff_tree_to_tree(None, Some(&commit_tree), None)
200                .context("Failed to create diff for initial commit")?
201        };
202
203        // Process each diff delta
204        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    /// Detect conventional commit type based on files and existing message
250    fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
251        let message = commit.message().unwrap_or("");
252
253        // Check if message already has conventional commit format
254        if let Some(existing_type) = Self::extract_conventional_type(message) {
255            return existing_type;
256        }
257
258        // Analyze file patterns
259        let files: Vec<&str> = file_changes
260            .file_list
261            .iter()
262            .map(|f| f.file.as_str())
263            .collect();
264
265        // Check for specific patterns
266        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    /// Extract conventional commit type from existing message
301    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    /// Check if a string is a valid conventional commit type
318    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    /// Detect scope based on file paths
335    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        // Analyze common path patterns
343        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    /// Generate a proposed conventional commit message
364    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 already properly formatted, return as-is
373        if Self::extract_conventional_type(current_message).is_some() {
374            return current_message.to_string();
375        }
376
377        // Generate description based on changes
378        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        // Format with scope if available
386        if scope.is_empty() {
387            format!("{}: {}", commit_type, description)
388        } else {
389            format!("{}({}): {}", commit_type, scope, description)
390        }
391    }
392
393    /// Generate description based on commit type and changes
394    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    /// Get diff summary statistics
413    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    /// Write full diff content to a file and return the path
461    fn write_diff_to_file(repo: &Repository, commit: &Commit) -> Result<String> {
462        // Get AI scratch directory
463        let ai_scratch_path = crate::utils::ai_scratch::get_ai_scratch_dir()
464            .context("Failed to determine AI scratch directory")?;
465
466        // Create diffs subdirectory
467        let diffs_dir = ai_scratch_path.join("diffs");
468        fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
469
470        // Create filename with commit hash
471        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' => "", // Header
507                'F' => "", // File header
508                _ => "",
509            };
510            diff_content.push_str(&format!("{}{}", prefix, content));
511            true
512        })
513        .context("Failed to format diff")?;
514
515        // Ensure the diff content ends with a newline to encourage literal block style
516        if !diff_content.ends_with('\n') {
517            diff_content.push('\n');
518        }
519
520        // Write diff content to file
521        fs::write(&diff_path, diff_content).context("Failed to write diff file")?;
522
523        // Return the path as a string
524        Ok(diff_path.to_string_lossy().to_string())
525    }
526}
527
528impl CommitInfoForAI {
529    /// Convert from basic CommitInfo by loading diff content
530    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    /// Convert from basic CommitAnalysis by loading diff content from file
546    pub fn from_commit_analysis(analysis: CommitAnalysis) -> Result<Self> {
547        // Read the actual diff content from the file
548        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}