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/// File changes statistics
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FileChanges {
46    /// Total number of files modified in this commit
47    pub total_files: usize,
48    /// Number of new files added in this commit
49    pub files_added: usize,
50    /// Number of files deleted in this commit
51    pub files_deleted: usize,
52    /// Array of files changed with their git status (M=modified, A=added, D=deleted)
53    pub file_list: Vec<FileChange>,
54}
55
56/// Individual file change
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FileChange {
59    /// Git status code (A=added, M=modified, D=deleted, R=renamed)
60    pub status: String,
61    /// Path to the file relative to repository root
62    pub file: String,
63}
64
65impl CommitInfo {
66    /// Create CommitInfo from git2::Commit
67    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        // TODO: Implement main branch detection
87        let in_main_branches = Vec::new();
88
89        // TODO: Implement commit analysis
90        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    /// Analyze a commit and generate analysis information
105    pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
106        // Get file changes
107        let file_changes = Self::analyze_file_changes(repo, commit)?;
108
109        // Detect conventional commit type based on files and message
110        let detected_type = Self::detect_commit_type(commit, &file_changes);
111
112        // Detect scope based on file paths
113        let detected_scope = Self::detect_scope(&file_changes);
114
115        // Generate proposed conventional commit message
116        let proposed_message =
117            Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
118
119        // Get diff summary
120        let diff_summary = Self::get_diff_summary(repo, commit)?;
121
122        // Write diff to file and get path
123        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    /// Analyze file changes in the commit
136    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        // Get the tree for this commit
142        let commit_tree = commit.tree().context("Failed to get commit tree")?;
143
144        // Get parent tree if available
145        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        // Create diff between parent and commit
158        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            // Initial commit - diff against empty tree
163            repo.diff_tree_to_tree(None, Some(&commit_tree), None)
164                .context("Failed to create diff for initial commit")?
165        };
166
167        // Process each diff delta
168        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    /// Detect conventional commit type based on files and existing message
214    fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
215        let message = commit.message().unwrap_or("");
216
217        // Check if message already has conventional commit format
218        if let Some(existing_type) = Self::extract_conventional_type(message) {
219            return existing_type;
220        }
221
222        // Analyze file patterns
223        let files: Vec<&str> = file_changes
224            .file_list
225            .iter()
226            .map(|f| f.file.as_str())
227            .collect();
228
229        // Check for specific patterns
230        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    /// Extract conventional commit type from existing message
265    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    /// Check if a string is a valid conventional commit type
282    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    /// Detect scope based on file paths
299    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        // Analyze common path patterns
307        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    /// Generate a proposed conventional commit message
328    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 already properly formatted, return as-is
337        if Self::extract_conventional_type(current_message).is_some() {
338            return current_message.to_string();
339        }
340
341        // Generate description based on changes
342        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        // Format with scope if available
350        if scope.is_empty() {
351            format!("{}: {}", commit_type, description)
352        } else {
353            format!("{}({}): {}", commit_type, scope, description)
354        }
355    }
356
357    /// Generate description based on commit type and changes
358    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    /// Get diff summary statistics
377    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    /// Write full diff content to a file and return the path
425    fn write_diff_to_file(repo: &Repository, commit: &Commit) -> Result<String> {
426        // Get AI scratch directory
427        let ai_scratch_path = crate::utils::ai_scratch::get_ai_scratch_dir()
428            .context("Failed to determine AI scratch directory")?;
429
430        // Create diffs subdirectory
431        let diffs_dir = ai_scratch_path.join("diffs");
432        fs::create_dir_all(&diffs_dir).context("Failed to create diffs directory")?;
433
434        // Create filename with commit hash
435        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' => "", // Header
471                'F' => "", // File header
472                _ => "",
473            };
474            diff_content.push_str(&format!("{}{}", prefix, content));
475            true
476        })
477        .context("Failed to format diff")?;
478
479        // Ensure the diff content ends with a newline to encourage literal block style
480        if !diff_content.ends_with('\n') {
481            diff_content.push('\n');
482        }
483
484        // Write diff content to file
485        fs::write(&diff_path, diff_content).context("Failed to write diff file")?;
486
487        // Return the path as a string
488        Ok(diff_path.to_string_lossy().to_string())
489    }
490}