use anyhow::{Context, Result};
use std::process::Command;
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("Git command failed: {0}")]
CommandFailed(String),
#[error("Not in a Git repository")]
NotInRepository,
#[error("Git binary not found")]
GitNotFound,
}
pub struct GitCommit;
impl GitCommit {
pub fn is_git_repository() -> Result<bool> {
let output = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.context("Failed to execute git command")?;
Ok(output.status.success())
}
pub fn format_message(emoji_code: &str, message: &str) -> String {
format!("{emoji_code} {message}")
}
pub fn commit(message: &str, dry_run: bool) -> Result<String> {
if !Self::is_git_repository()? {
return Err(GitError::NotInRepository.into());
}
if dry_run {
return Ok(format!(
"DRY RUN: Would execute: git commit -m \"{message}\"",
));
}
let output = Command::new("git")
.args(["commit", "-m", message])
.output()
.context("Failed to execute git commit")?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut result = format!("Git commit successful:\n{stdout}");
if !stderr.is_empty() {
result.push_str(&format!("\nPre-commit output:\n{stderr}"));
}
Ok(result)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("failed")
|| stderr.contains("error")
|| stderr.contains("Failed")
|| stderr.contains("Error")
{
Err(GitError::CommandFailed(stderr.to_string()).into())
} else {
Ok(stderr.to_string())
}
}
}
pub fn status() -> Result<String> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.output()
.context("Failed to execute git status")?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(GitError::CommandFailed(stderr.to_string()).into())
}
}
pub fn has_staged_changes() -> Result<bool> {
let status = Self::status()?;
Ok(status.lines().any(|line| {
if line.len() >= 2 {
matches!(line.chars().next(), Some('A' | 'M' | 'D' | 'R' | 'C'))
} else {
false
}
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_message() {
assert_eq!(
GitCommit::format_message(":sparkles:", "Add new feature"),
":sparkles: Add new feature"
);
assert_eq!(
GitCommit::format_message(":bug:", "Fix login issue"),
":bug: Fix login issue"
);
assert_eq!(
GitCommit::format_message(":memo:", "Update documentation"),
":memo: Update documentation"
);
}
#[test]
fn test_format_message_with_empty_inputs() {
assert_eq!(GitCommit::format_message("", "message"), " message");
assert_eq!(GitCommit::format_message(":sparkles:", ""), ":sparkles: ");
assert_eq!(GitCommit::format_message("", ""), " ");
}
#[test]
fn test_format_message_with_special_characters() {
assert_eq!(
GitCommit::format_message(":art:", "Fix: resolve \"critical\" issue"),
":art: Fix: resolve \"critical\" issue"
);
assert_eq!(
GitCommit::format_message(":sparkles:", "Add support for UTF-8 🎉"),
":sparkles: Add support for UTF-8 🎉"
);
}
#[test]
fn test_commit_dry_run() {
let result = GitCommit::commit("Test message", true);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("DRY RUN"));
assert!(output.contains("git commit -m \"Test message\""));
}
#[test]
fn test_commit_dry_run_with_formatted_message() {
let formatted_message = GitCommit::format_message(":sparkles:", "Add new feature");
let result = GitCommit::commit(&formatted_message, true);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("DRY RUN"));
assert!(output.contains(":sparkles: Add new feature"));
}
#[test]
fn test_commit_success_message_format() {
let result = GitCommit::commit("Test message", true);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("DRY RUN"));
assert!(output.contains("git commit -m \"Test message\""));
}
#[test]
fn test_git_error_display() {
let error = GitError::NotInRepository;
assert_eq!(error.to_string(), "Not in a Git repository");
let error = GitError::GitNotFound;
assert_eq!(error.to_string(), "Git binary not found");
let error = GitError::CommandFailed("Some error".to_string());
assert_eq!(error.to_string(), "Git command failed: Some error");
}
#[test]
#[ignore = "requires git repository"]
fn test_is_git_repository_in_git_repo() {
let result = GitCommit::is_git_repository();
assert!(result.is_ok());
}
#[test]
#[ignore = "requires git repository with staged changes"]
fn test_has_staged_changes() {
let result = GitCommit::has_staged_changes();
assert!(result.is_ok());
}
#[test]
#[ignore = "requires git repository"]
fn test_status() {
let result = GitCommit::status();
assert!(result.is_ok());
}
#[test]
fn test_status_parsing_logic() {
let status_with_staged = "M modified_file.txt\nA new_file.txt\n?? untracked.txt";
let has_staged = status_with_staged.lines().any(|line| {
if line.len() >= 2 {
matches!(line.chars().next(), Some('A' | 'M' | 'D' | 'R' | 'C'))
} else {
false
}
});
assert!(has_staged);
let status_without_staged = "?? untracked1.txt\n?? untracked2.txt";
let has_staged = status_without_staged.lines().any(|line| {
if line.len() >= 2 {
matches!(line.chars().next(), Some('A' | 'M' | 'D' | 'R' | 'C'))
} else {
false
}
});
assert!(!has_staged);
let empty_status = "";
let has_staged = empty_status.lines().any(|line| {
if line.len() >= 2 {
matches!(line.chars().next(), Some('A' | 'M' | 'D' | 'R' | 'C'))
} else {
false
}
});
assert!(!has_staged);
}
#[test]
fn test_git_status_change_types() {
let test_cases = vec![
("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), ];
for (status_line, expected_staged) in test_cases {
let has_staged = if status_line.len() >= 2 {
matches!(
status_line.chars().next(),
Some('A' | 'M' | 'D' | 'R' | 'C')
)
} else {
false
};
assert_eq!(
has_staged, expected_staged,
"Failed for status line: '{status_line}'"
);
}
}
}