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};
7
8/// Commit information structure
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CommitInfo {
11    /// Full SHA-1 hash of the commit
12    pub hash: String,
13    /// Commit author name and email address
14    pub author: String,
15    /// Commit date in ISO format with timezone
16    pub date: DateTime<FixedOffset>,
17    /// The original commit message as written by the author
18    pub original_message: String,
19    /// Array of remote main branches that contain this commit
20    pub in_main_branches: Vec<String>,
21    /// Automated analysis of the commit including type detection and proposed message
22    pub analysis: CommitAnalysis,
23}
24
25/// Commit analysis information
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CommitAnalysis {
28    /// Automatically detected conventional commit type (feat, fix, docs, test, chore, etc.)
29    pub detected_type: String,
30    /// Automatically detected scope based on file paths (cli, git, data, etc.)
31    pub detected_scope: String,
32    /// AI-generated conventional commit message based on file changes
33    pub proposed_message: String,
34    /// Detailed statistics about file changes in this commit
35    pub file_changes: FileChanges,
36    /// Git diff --stat output showing lines changed per file
37    pub diff_summary: String,
38}
39
40/// File changes statistics
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct FileChanges {
43    /// Total number of files modified in this commit
44    pub total_files: usize,
45    /// Number of new files added in this commit
46    pub files_added: usize,
47    /// Number of files deleted in this commit
48    pub files_deleted: usize,
49    /// Array of files changed with their git status (M=modified, A=added, D=deleted)
50    pub file_list: Vec<FileChange>,
51}
52
53/// Individual file change
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct FileChange {
56    /// Git status code (A=added, M=modified, D=deleted, R=renamed)
57    pub status: String,
58    /// Path to the file relative to repository root
59    pub file: String,
60}
61
62impl CommitInfo {
63    /// Create CommitInfo from git2::Commit
64    pub fn from_git_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
65        let hash = commit.id().to_string();
66
67        let author = format!(
68            "{} <{}>",
69            commit.author().name().unwrap_or("Unknown"),
70            commit.author().email().unwrap_or("unknown@example.com")
71        );
72
73        let timestamp = commit.author().when();
74        let date = DateTime::from_timestamp(timestamp.seconds(), 0)
75            .context("Invalid commit timestamp")?
76            .with_timezone(
77                &FixedOffset::east_opt(timestamp.offset_minutes() * 60)
78                    .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()),
79            );
80
81        let original_message = commit.message().unwrap_or("").to_string();
82
83        // TODO: Implement main branch detection
84        let in_main_branches = Vec::new();
85
86        // TODO: Implement commit analysis
87        let analysis = CommitAnalysis::analyze_commit(repo, commit)?;
88
89        Ok(Self {
90            hash,
91            author,
92            date,
93            original_message,
94            in_main_branches,
95            analysis,
96        })
97    }
98}
99
100impl CommitAnalysis {
101    /// Analyze a commit and generate analysis information
102    pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
103        // Get file changes
104        let file_changes = Self::analyze_file_changes(repo, commit)?;
105
106        // Detect conventional commit type based on files and message
107        let detected_type = Self::detect_commit_type(commit, &file_changes);
108
109        // Detect scope based on file paths
110        let detected_scope = Self::detect_scope(&file_changes);
111
112        // Generate proposed conventional commit message
113        let proposed_message =
114            Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
115
116        // Get diff summary
117        let diff_summary = Self::get_diff_summary(repo, commit)?;
118
119        Ok(Self {
120            detected_type,
121            detected_scope,
122            proposed_message,
123            file_changes,
124            diff_summary,
125        })
126    }
127
128    /// Analyze file changes in the commit
129    fn analyze_file_changes(repo: &Repository, commit: &Commit) -> Result<FileChanges> {
130        let mut file_list = Vec::new();
131        let mut files_added = 0;
132        let mut files_deleted = 0;
133
134        // Get the tree for this commit
135        let commit_tree = commit.tree().context("Failed to get commit tree")?;
136
137        // Get parent tree if available
138        let parent_tree = if commit.parent_count() > 0 {
139            Some(
140                commit
141                    .parent(0)
142                    .context("Failed to get parent commit")?
143                    .tree()
144                    .context("Failed to get parent tree")?,
145            )
146        } else {
147            None
148        };
149
150        // Create diff between parent and commit
151        let diff = if let Some(parent_tree) = parent_tree {
152            repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
153                .context("Failed to create diff")?
154        } else {
155            // Initial commit - diff against empty tree
156            repo.diff_tree_to_tree(None, Some(&commit_tree), None)
157                .context("Failed to create diff for initial commit")?
158        };
159
160        // Process each diff delta
161        diff.foreach(
162            &mut |delta, _progress| {
163                let status = match delta.status() {
164                    git2::Delta::Added => {
165                        files_added += 1;
166                        "A"
167                    }
168                    git2::Delta::Deleted => {
169                        files_deleted += 1;
170                        "D"
171                    }
172                    git2::Delta::Modified => "M",
173                    git2::Delta::Renamed => "R",
174                    git2::Delta::Copied => "C",
175                    git2::Delta::Typechange => "T",
176                    _ => "?",
177                };
178
179                if let Some(path) = delta.new_file().path() {
180                    if let Some(path_str) = path.to_str() {
181                        file_list.push(FileChange {
182                            status: status.to_string(),
183                            file: path_str.to_string(),
184                        });
185                    }
186                }
187
188                true
189            },
190            None,
191            None,
192            None,
193        )
194        .context("Failed to process diff")?;
195
196        let total_files = file_list.len();
197
198        Ok(FileChanges {
199            total_files,
200            files_added,
201            files_deleted,
202            file_list,
203        })
204    }
205
206    /// Detect conventional commit type based on files and existing message
207    fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
208        let message = commit.message().unwrap_or("");
209
210        // Check if message already has conventional commit format
211        if let Some(existing_type) = Self::extract_conventional_type(message) {
212            return existing_type;
213        }
214
215        // Analyze file patterns
216        let files: Vec<&str> = file_changes
217            .file_list
218            .iter()
219            .map(|f| f.file.as_str())
220            .collect();
221
222        // Check for specific patterns
223        if files
224            .iter()
225            .any(|f| f.contains("test") || f.contains("spec"))
226        {
227            "test".to_string()
228        } else if files
229            .iter()
230            .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
231        {
232            "docs".to_string()
233        } else if files
234            .iter()
235            .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
236        {
237            if file_changes.files_added > 0 {
238                "feat".to_string()
239            } else {
240                "chore".to_string()
241            }
242        } else if file_changes.files_added > 0
243            && files
244                .iter()
245                .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
246        {
247            "feat".to_string()
248        } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
249            "fix".to_string()
250        } else if file_changes.files_deleted > file_changes.files_added {
251            "refactor".to_string()
252        } else {
253            "chore".to_string()
254        }
255    }
256
257    /// Extract conventional commit type from existing message
258    fn extract_conventional_type(message: &str) -> Option<String> {
259        let first_line = message.lines().next().unwrap_or("");
260        if let Some(colon_pos) = first_line.find(':') {
261            let prefix = &first_line[..colon_pos];
262            if let Some(paren_pos) = prefix.find('(') {
263                let type_part = &prefix[..paren_pos];
264                if Self::is_valid_conventional_type(type_part) {
265                    return Some(type_part.to_string());
266                }
267            } else if Self::is_valid_conventional_type(prefix) {
268                return Some(prefix.to_string());
269            }
270        }
271        None
272    }
273
274    /// Check if a string is a valid conventional commit type
275    fn is_valid_conventional_type(s: &str) -> bool {
276        matches!(
277            s,
278            "feat"
279                | "fix"
280                | "docs"
281                | "style"
282                | "refactor"
283                | "test"
284                | "chore"
285                | "build"
286                | "ci"
287                | "perf"
288        )
289    }
290
291    /// Detect scope based on file paths
292    fn detect_scope(file_changes: &FileChanges) -> String {
293        let files: Vec<&str> = file_changes
294            .file_list
295            .iter()
296            .map(|f| f.file.as_str())
297            .collect();
298
299        // Analyze common path patterns
300        if files.iter().any(|f| f.starts_with("src/cli/")) {
301            "cli".to_string()
302        } else if files.iter().any(|f| f.starts_with("src/git/")) {
303            "git".to_string()
304        } else if files.iter().any(|f| f.starts_with("src/data/")) {
305            "data".to_string()
306        } else if files.iter().any(|f| f.starts_with("tests/")) {
307            "test".to_string()
308        } else if files.iter().any(|f| f.starts_with("docs/")) {
309            "docs".to_string()
310        } else if files
311            .iter()
312            .any(|f| f.contains("Cargo.toml") || f.contains("deny.toml"))
313        {
314            "deps".to_string()
315        } else {
316            "".to_string()
317        }
318    }
319
320    /// Generate a proposed conventional commit message
321    fn generate_proposed_message(
322        commit: &Commit,
323        commit_type: &str,
324        scope: &str,
325        file_changes: &FileChanges,
326    ) -> String {
327        let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
328
329        // If already properly formatted, return as-is
330        if Self::extract_conventional_type(current_message).is_some() {
331            return current_message.to_string();
332        }
333
334        // Generate description based on changes
335        let description =
336            if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
337                current_message.to_string()
338            } else {
339                Self::generate_description(commit_type, file_changes)
340            };
341
342        // Format with scope if available
343        if scope.is_empty() {
344            format!("{}: {}", commit_type, description)
345        } else {
346            format!("{}({}): {}", commit_type, scope, description)
347        }
348    }
349
350    /// Generate description based on commit type and changes
351    fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
352        match commit_type {
353            "feat" => {
354                if file_changes.total_files == 1 {
355                    format!("add {}", file_changes.file_list[0].file)
356                } else {
357                    format!("add {} new features", file_changes.total_files)
358                }
359            }
360            "fix" => "resolve issues".to_string(),
361            "docs" => "update documentation".to_string(),
362            "test" => "add tests".to_string(),
363            "refactor" => "improve code structure".to_string(),
364            "chore" => "update project files".to_string(),
365            _ => "update project".to_string(),
366        }
367    }
368
369    /// Get diff summary statistics
370    fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
371        let commit_tree = commit.tree().context("Failed to get commit tree")?;
372
373        let parent_tree = if commit.parent_count() > 0 {
374            Some(
375                commit
376                    .parent(0)
377                    .context("Failed to get parent commit")?
378                    .tree()
379                    .context("Failed to get parent tree")?,
380            )
381        } else {
382            None
383        };
384
385        let diff = if let Some(parent_tree) = parent_tree {
386            repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
387                .context("Failed to create diff")?
388        } else {
389            repo.diff_tree_to_tree(None, Some(&commit_tree), None)
390                .context("Failed to create diff for initial commit")?
391        };
392
393        let stats = diff.stats().context("Failed to get diff stats")?;
394
395        let mut summary = String::new();
396        for i in 0..stats.files_changed() {
397            if let Some(path) = diff
398                .get_delta(i)
399                .and_then(|d| d.new_file().path())
400                .and_then(|p| p.to_str())
401            {
402                let insertions = stats.insertions();
403                let deletions = stats.deletions();
404                summary.push_str(&format!(
405                    " {} | {} +{} -{}\n",
406                    path,
407                    insertions + deletions,
408                    insertions,
409                    deletions
410                ));
411            }
412        }
413
414        Ok(summary)
415    }
416}