♻️ refactor(config):重构ConfigManager,添加with_path_fresh方法用于初始化新配置 🔧 fix(git):改进跨平台路径处理,增强git仓库检测的鲁棒性 ✅ test(tests):添加全面的集成测试,覆盖所有命令和跨平台场景
379 lines
11 KiB
Rust
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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|