use super::GitRepo; use anyhow::{bail, Result}; use chrono::Local; /// Commit builder for creating commits pub struct CommitBuilder { commit_type: Option, scope: Option, description: Option, body: Option, footer: Option, message: Option, breaking: bool, sign: bool, amend: bool, no_verify: bool, format: crate::config::CommitFormat, } impl CommitBuilder { /// Create new commit builder pub fn new() -> Self { Self { commit_type: None, scope: None, description: None, body: None, footer: None, message: None, breaking: false, sign: false, amend: false, no_verify: false, format: crate::config::CommitFormat::Conventional, } } /// Set commit type pub fn commit_type(mut self, commit_type: impl Into) -> Self { self.commit_type = Some(commit_type.into()); self } /// Set scope pub fn scope(mut self, scope: impl Into) -> Self { self.scope = Some(scope.into()); self } /// Set scope (optional) pub fn scope_opt(mut self, scope: Option) -> Self { self.scope = scope; self } /// Set description pub fn description(mut self, description: impl Into) -> Self { self.description = Some(description.into()); self } /// Set body pub fn body(mut self, body: impl Into) -> Self { self.body = Some(body.into()); self } /// Set body (optional) pub fn body_opt(mut self, body: Option) -> Self { self.body = body; self } /// Set footer pub fn footer(mut self, footer: impl Into) -> Self { self.footer = Some(footer.into()); self } /// Set message pub fn message(mut self, message: impl Into) -> Self { self.message = Some(message.into()); self } /// Mark as breaking change pub fn breaking(mut self, breaking: bool) -> Self { self.breaking = breaking; self } /// Sign the commit pub fn sign(mut self, sign: bool) -> Self { self.sign = sign; self } /// Amend previous commit pub fn amend(mut self, amend: bool) -> Self { self.amend = amend; self } /// Skip pre-commit hooks pub fn no_verify(mut self, no_verify: bool) -> Self { self.no_verify = no_verify; self } /// Set commit format pub fn format(mut self, format: crate::config::CommitFormat) -> Self { self.format = format; self } /// Build commit message pub fn build_message(&self) -> Result { if let Some(ref msg) = self.message { return Ok(msg.clone()); } let commit_type = self.commit_type.as_ref() .ok_or_else(|| anyhow::anyhow!("Commit type is required"))?; let description = self.description.as_ref() .ok_or_else(|| anyhow::anyhow!("Description is required"))?; let message = match self.format { crate::config::CommitFormat::Conventional => { crate::utils::formatter::format_conventional_commit( commit_type, self.scope.as_deref(), description, self.body.as_deref(), self.footer.as_deref(), self.breaking, ) } crate::config::CommitFormat::Commitlint => { crate::utils::formatter::format_commitlint_commit( commit_type, self.scope.as_deref(), description, self.body.as_deref(), self.footer.as_deref(), None, ) } }; Ok(message) } /// Execute commit pub fn execute(&self, repo: &GitRepo) -> Result> { let message = self.build_message()?; if self.amend { self.amend_commit(repo, &message)?; Ok(None) } else { repo.commit(&message, self.sign)?; Ok(None) } } fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> { use std::process::Command; let mut args = vec!["commit", "--amend"]; if self.no_verify { args.push("--no-verify"); } args.push("-m"); args.push(message); if self.sign { args.push("-S"); } let output = Command::new("git") .args(&args) .current_dir(repo.path()) .output()?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let error_msg = if stderr.is_empty() { if stdout.is_empty() { "GPG signing failed. Please check:\n\ 1. GPG signing key is configured (git config --get user.signingkey)\n\ 2. GPG agent is running\n\ 3. You can sign commits manually (try: git commit --amend -S)".to_string() } else { stdout.to_string() } } else { stderr.to_string() }; bail!("Failed to amend commit: {}", error_msg); } Ok(()) } } impl Default for CommitBuilder { fn default() -> Self { Self::new() } } /// Create a date-based commit message pub fn create_date_commit_message(prefix: Option<&str>) -> String { let now = Local::now(); let date_str = now.format("%Y-%m-%d").to_string(); match prefix { Some(p) => format!("{}: {}", p, date_str), None => format!("chore: update {}", date_str), } } /// Commit type suggestions based on diff pub fn suggest_commit_type(diff: &str) -> Vec<&'static str> { let mut suggestions = vec![]; // Check for test files if diff.contains("test") || diff.contains("spec") || diff.contains("__tests__") { suggestions.push("test"); } // Check for documentation if diff.contains("README") || diff.contains(".md") || diff.contains("docs/") { suggestions.push("docs"); } // Check for configuration files if diff.contains("config") || diff.contains(".json") || diff.contains(".yaml") || diff.contains(".toml") { suggestions.push("chore"); } // Check for dependencies if diff.contains("Cargo.toml") || diff.contains("package.json") || diff.contains("requirements.txt") { suggestions.push("build"); } // Check for CI if diff.contains(".github/") || diff.contains(".gitlab-") || diff.contains("Jenkinsfile") { suggestions.push("ci"); } // Default suggestions if suggestions.is_empty() { suggestions.extend(&["feat", "fix", "refactor"]); } suggestions } /// Parse existing commit message pub fn parse_commit_message(message: &str) -> ParsedCommit { let lines: Vec<&str> = message.lines().collect(); if lines.is_empty() { return ParsedCommit::default(); } let first_line = lines[0]; // Try to parse as conventional commit if let Some(colon_pos) = first_line.find(':') { let type_part = &first_line[..colon_pos]; let description = first_line[colon_pos + 1..].trim(); let breaking = type_part.ends_with('!'); let type_part = type_part.trim_end_matches('!'); let (commit_type, scope) = if let Some(open) = type_part.find('(') { if let Some(close) = type_part.find(')') { let t = &type_part[..open]; let s = &type_part[open + 1..close]; (Some(t.to_string()), Some(s.to_string())) } else { (Some(type_part.to_string()), None) } } else { (Some(type_part.to_string()), None) }; // Extract body and footer let mut body_lines = vec![]; let mut footer_lines = vec![]; let mut in_footer = false; for line in &lines[1..] { if line.trim().is_empty() { continue; } if line.starts_with("BREAKING CHANGE:") || line.starts_with("Closes") || line.starts_with("Fixes") || line.starts_with("Refs") || line.starts_with("Co-authored-by:") { in_footer = true; } if in_footer { footer_lines.push(line.to_string()); } else { body_lines.push(line.to_string()); } } return ParsedCommit { commit_type, scope, description: Some(description.to_string()), body: if body_lines.is_empty() { None } else { Some(body_lines.join("\n")) }, footer: if footer_lines.is_empty() { None } else { Some(footer_lines.join("\n")) }, breaking, }; } // Non-conventional commit ParsedCommit { description: Some(first_line.to_string()), ..Default::default() } } /// Parsed commit structure #[derive(Debug, Clone, Default)] pub struct ParsedCommit { pub commit_type: Option, pub scope: Option, pub description: Option, pub body: Option, pub footer: Option, pub breaking: bool, } impl ParsedCommit { /// Convert back to commit message pub fn to_message(&self, format: crate::config::CommitFormat) -> String { let commit_type = self.commit_type.as_deref().unwrap_or("chore"); let description = self.description.as_deref().unwrap_or("update"); match format { crate::config::CommitFormat::Conventional => { crate::utils::formatter::format_conventional_commit( commit_type, self.scope.as_deref(), description, self.body.as_deref(), self.footer.as_deref(), self.breaking, ) } crate::config::CommitFormat::Commitlint => { crate::utils::formatter::format_commitlint_commit( commit_type, self.scope.as_deref(), description, self.body.as_deref(), self.footer.as_deref(), None, ) } } } }