1use anyhow::{Context, Result};
2use std::process::Command;
3
4#[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
15pub struct GitCommit;
17
18impl GitCommit {
19 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 pub fn format_message(emoji_code: &str, message: &str) -> String {
31 format!("{emoji_code} {message}")
32 }
33
34 pub fn commit(message: &str, dry_run: bool) -> Result<String> {
36 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 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 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 pub fn has_staged_changes() -> Result<bool> {
95 let status = Self::status()?;
96 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 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 }
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 #[test]
202 #[ignore = "requires git repository"]
203 fn test_is_git_repository_in_git_repo() {
204 let result = GitCommit::is_git_repository();
206 assert!(result.is_ok());
207 }
210
211 #[test]
212 #[ignore = "requires git repository with staged changes"]
213 fn test_has_staged_changes() {
214 let result = GitCommit::has_staged_changes();
216 assert!(result.is_ok());
217 }
219
220 #[test]
221 #[ignore = "requires git repository"]
222 fn test_status() {
223 let result = GitCommit::status();
225 assert!(result.is_ok());
226 }
228
229 #[test]
231 fn test_status_parsing_logic() {
232 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 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 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 let test_cases = vec![
272 ("A new_file.txt", true), ("M modified.txt", true), ("D deleted.txt", true), ("R old.txt -> new.txt", true), ("C copied.txt", true), ("?? untracked.txt", false), (" M workspace_modified.txt", false), ("MM both_modified.txt", true), ];
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}