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