auto_gitmoji/
commit.rs

1use anyhow::{Context, Result};
2use std::process::Command;
3
4/// Git command execution errors
5#[derive(Debug, thiserror::Error)]
6pub enum GitError {
7    #[error("Git command failed: {0}")]
8    CommandFailed(String),
9    #[error("Not in a Git repository")]
10    NotInRepository,
11    #[error("Git binary not found")]
12    GitNotFound,
13}
14
15/// Git commit functionality
16pub struct GitCommit;
17
18impl GitCommit {
19    /// Check if we're in a Git repository
20    pub fn is_git_repository() -> Result<bool> {
21        let output = Command::new("git")
22            .args(["rev-parse", "--is-inside-work-tree"])
23            .output()
24            .context("Failed to execute git command")?;
25
26        Ok(output.status.success())
27    }
28
29    /// Format a commit message with emoji
30    pub fn format_message(emoji_code: &str, message: &str) -> String {
31        format!("{emoji_code} {message}")
32    }
33
34    /// Execute git commit with the formatted message
35    pub fn commit(message: &str, dry_run: bool) -> Result<String> {
36        // Verify we're in a Git repository
37        if !Self::is_git_repository()? {
38            return Err(GitError::NotInRepository.into());
39        }
40
41        if dry_run {
42            return Ok(format!(
43                "DRY RUN: Would execute: git commit -m \"{message}\"",
44            ));
45        }
46
47        let output = Command::new("git")
48            .args(["commit", "-m", message])
49            .output()
50            .context("Failed to execute git commit")?;
51
52        if output.status.success() {
53            let stdout = String::from_utf8_lossy(&output.stdout);
54            let stderr = String::from_utf8_lossy(&output.stderr);
55
56            // Include both stdout and stderr in success message
57            // Pre-commit hooks often output to stderr even on success
58            let mut result = format!("Git commit successful:\n{stdout}");
59            if !stderr.is_empty() {
60                result.push_str(&format!("\nPre-commit output:\n{stderr}"));
61            }
62            Ok(result)
63        } else {
64            let stderr = String::from_utf8_lossy(&output.stderr);
65            if stderr.contains("failed")
66                || stderr.contains("error")
67                || stderr.contains("Failed")
68                || stderr.contains("Error")
69            {
70                Err(GitError::CommandFailed(stderr.to_string()).into())
71            } else {
72                Ok(stderr.to_string())
73            }
74        }
75    }
76
77    /// Get git status to show staged changes
78    pub fn status() -> Result<String> {
79        let output = Command::new("git")
80            .args(["status", "--porcelain"])
81            .output()
82            .context("Failed to execute git status")?;
83
84        if output.status.success() {
85            let stdout = String::from_utf8_lossy(&output.stdout);
86            Ok(stdout.to_string())
87        } else {
88            let stderr = String::from_utf8_lossy(&output.stderr);
89            Err(GitError::CommandFailed(stderr.to_string()).into())
90        }
91    }
92
93    /// Check if there are staged changes to commit
94    pub fn has_staged_changes() -> Result<bool> {
95        let status = Self::status()?;
96        // Look for staged changes (lines starting with A, M, D, R, C in first column)
97        Ok(status.lines().any(|line| {
98            if line.len() >= 2 {
99                matches!(line.chars().next(), Some('A' | 'M' | 'D' | 'R' | 'C'))
100            } else {
101                false
102            }
103        }))
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_format_message() {
113        assert_eq!(
114            GitCommit::format_message(":sparkles:", "Add new feature"),
115            ":sparkles: Add new feature"
116        );
117
118        assert_eq!(
119            GitCommit::format_message(":bug:", "Fix login issue"),
120            ":bug: Fix login issue"
121        );
122
123        assert_eq!(
124            GitCommit::format_message(":memo:", "Update documentation"),
125            ":memo: Update documentation"
126        );
127    }
128
129    #[test]
130    fn test_format_message_with_empty_inputs() {
131        assert_eq!(GitCommit::format_message("", "message"), " message");
132
133        assert_eq!(GitCommit::format_message(":sparkles:", ""), ":sparkles: ");
134
135        assert_eq!(GitCommit::format_message("", ""), " ");
136    }
137
138    #[test]
139    fn test_format_message_with_special_characters() {
140        assert_eq!(
141            GitCommit::format_message(":art:", "Fix: resolve \"critical\" issue"),
142            ":art: Fix: resolve \"critical\" issue"
143        );
144
145        assert_eq!(
146            GitCommit::format_message(":sparkles:", "Add support for UTF-8 🎉"),
147            ":sparkles: Add support for UTF-8 🎉"
148        );
149    }
150
151    #[test]
152    fn test_commit_dry_run() {
153        let result = GitCommit::commit("Test message", true);
154        assert!(result.is_ok());
155
156        let output = result.unwrap();
157        assert!(output.contains("DRY RUN"));
158        assert!(output.contains("git commit -m \"Test message\""));
159    }
160
161    #[test]
162    fn test_commit_dry_run_with_formatted_message() {
163        let formatted_message = GitCommit::format_message(":sparkles:", "Add new feature");
164        let result = GitCommit::commit(&formatted_message, true);
165
166        assert!(result.is_ok());
167        let output = result.unwrap();
168        assert!(output.contains("DRY RUN"));
169        assert!(output.contains(":sparkles: Add new feature"));
170    }
171
172    #[test]
173    fn test_commit_success_message_format() {
174        // Test that success messages properly format stdout and stderr
175        let result = GitCommit::commit("Test message", true);
176        assert!(result.is_ok());
177
178        let output = result.unwrap();
179        assert!(output.contains("DRY RUN"));
180        assert!(output.contains("git commit -m \"Test message\""));
181
182        // The dry run doesn't actually execute git, so it won't have pre-commit output
183        // But this tests the message formatting structure
184    }
185
186    #[test]
187    fn test_git_error_display() {
188        let error = GitError::NotInRepository;
189        assert_eq!(error.to_string(), "Not in a Git repository");
190
191        let error = GitError::GitNotFound;
192        assert_eq!(error.to_string(), "Git binary not found");
193
194        let error = GitError::CommandFailed("Some error".to_string());
195        assert_eq!(error.to_string(), "Git command failed: Some error");
196    }
197
198    // Note: The following tests require an actual Git repository to run
199    // They are marked with #[ignore] to skip by default
200
201    #[test]
202    #[ignore = "requires git repository"]
203    fn test_is_git_repository_in_git_repo() {
204        // This test should pass when run in a Git repository
205        let result = GitCommit::is_git_repository();
206        assert!(result.is_ok());
207        // When in a git repo, this should return true
208        // assert!(result.unwrap());
209    }
210
211    #[test]
212    #[ignore = "requires git repository with staged changes"]
213    fn test_has_staged_changes() {
214        // This test requires a Git repository with staged changes
215        let result = GitCommit::has_staged_changes();
216        assert!(result.is_ok());
217        // Result depends on whether there are staged changes
218    }
219
220    #[test]
221    #[ignore = "requires git repository"]
222    fn test_status() {
223        // This test requires a Git repository
224        let result = GitCommit::status();
225        assert!(result.is_ok());
226        // The result should be a string (could be empty if clean repo)
227    }
228
229    // Mock-based tests for Git operations
230    #[test]
231    fn test_status_parsing_logic() {
232        // Test the logic for detecting staged changes from git status output
233
234        // Simulate git status output with staged changes
235        let status_with_staged = "M  modified_file.txt\nA  new_file.txt\n?? untracked.txt";
236        let has_staged = status_with_staged.lines().any(|line| {
237            if line.len() >= 2 {
238                matches!(line.chars().next(), Some('A' | 'M' | 'D' | 'R' | 'C'))
239            } else {
240                false
241            }
242        });
243        assert!(has_staged);
244
245        // Simulate git status output without staged changes
246        let status_without_staged = "?? untracked1.txt\n?? untracked2.txt";
247        let has_staged = status_without_staged.lines().any(|line| {
248            if line.len() >= 2 {
249                matches!(line.chars().next(), Some('A' | 'M' | 'D' | 'R' | 'C'))
250            } else {
251                false
252            }
253        });
254        assert!(!has_staged);
255
256        // Empty status (clean repo)
257        let empty_status = "";
258        let has_staged = empty_status.lines().any(|line| {
259            if line.len() >= 2 {
260                matches!(line.chars().next(), Some('A' | 'M' | 'D' | 'R' | 'C'))
261            } else {
262                false
263            }
264        });
265        assert!(!has_staged);
266    }
267
268    #[test]
269    fn test_git_status_change_types() {
270        // Test different types of git status changes
271        let test_cases = vec![
272            ("A  new_file.txt", true),            // Added
273            ("M  modified.txt", true),            // Modified
274            ("D  deleted.txt", true),             // Deleted
275            ("R  old.txt -> new.txt", true),      // Renamed
276            ("C  copied.txt", true),              // Copied
277            ("?? untracked.txt", false),          // Untracked
278            (" M workspace_modified.txt", false), // Workspace change only
279            ("MM both_modified.txt", true),       // Both staged and workspace
280        ];
281
282        for (status_line, expected_staged) in test_cases {
283            let has_staged = if status_line.len() >= 2 {
284                matches!(
285                    status_line.chars().next(),
286                    Some('A' | 'M' | 'D' | 'R' | 'C')
287                )
288            } else {
289                false
290            };
291            assert_eq!(
292                has_staged, expected_staged,
293                "Failed for status line: '{status_line}'"
294            );
295        }
296    }
297}