Files
QuiCommit/src/git/commit.rs
SidneyZhang e822ba1f54 feat(commands):为所有命令添加config_path参数支持,实现自定义配置文件路径
♻️ refactor(config):重构ConfigManager,添加with_path_fresh方法用于初始化新配置
🔧 fix(git):改进跨平台路径处理,增强git仓库检测的鲁棒性
 test(tests):添加全面的集成测试,覆盖所有命令和跨平台场景
2026-02-14 14:28:11 +08:00

379 lines
11 KiB
Rust

use super::GitRepo;
use anyhow::{bail, Result};
use chrono::Local;
/// Commit builder for creating commits
pub struct CommitBuilder {
commit_type: Option<String>,
scope: Option<String>,
description: Option<String>,
body: Option<String>,
footer: Option<String>,
message: Option<String>,
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<String>) -> Self {
self.commit_type = Some(commit_type.into());
self
}
/// Set scope
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scope = Some(scope.into());
self
}
/// Set scope (optional)
pub fn scope_opt(mut self, scope: Option<String>) -> Self {
self.scope = scope;
self
}
/// Set description
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Set body
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
/// Set body (optional)
pub fn body_opt(mut self, body: Option<String>) -> Self {
self.body = body;
self
}
/// Set footer
pub fn footer(mut self, footer: impl Into<String>) -> Self {
self.footer = Some(footer.into());
self
}
/// Set message
pub fn message(mut self, message: impl Into<String>) -> 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<String> {
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<Option<String>> {
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<String>,
pub scope: Option<String>,
pub description: Option<String>,
pub body: Option<String>,
pub footer: Option<String>,
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,
)
}
}
}
}