399 lines
12 KiB
Rust
399 lines
12 KiB
Rust
use anyhow::{bail, Context, Result};
|
|
use clap::Parser;
|
|
use colored::Colorize;
|
|
use dialoguer::{Confirm, Input, Select};
|
|
|
|
use crate::config::{Language, manager::ConfigManager};
|
|
use crate::config::CommitFormat;
|
|
use crate::generator::ContentGenerator;
|
|
use crate::git::{find_repo, GitRepo};
|
|
use crate::git::commit::{CommitBuilder, create_date_commit_message};
|
|
use crate::i18n::Messages;
|
|
use crate::utils::validators::get_commit_types;
|
|
|
|
/// Generate and execute conventional commits
|
|
#[derive(Parser)]
|
|
pub struct CommitCommand {
|
|
/// Commit type
|
|
#[arg(long)]
|
|
commit_type: Option<String>,
|
|
|
|
/// Commit scope
|
|
#[arg(short, long)]
|
|
scope: Option<String>,
|
|
|
|
/// Commit description/subject
|
|
#[arg(short, long)]
|
|
message: Option<String>,
|
|
|
|
/// Commit body
|
|
#[arg(long)]
|
|
body: Option<String>,
|
|
|
|
/// Mark as breaking change
|
|
#[arg(short, long)]
|
|
breaking: bool,
|
|
|
|
/// Use date-based commit message
|
|
#[arg(short, long)]
|
|
date: bool,
|
|
|
|
/// Manual input (skip AI generation)
|
|
#[arg(long)]
|
|
manual: bool,
|
|
|
|
/// Sign the commit
|
|
#[arg(short = 'S', long)]
|
|
sign: bool,
|
|
|
|
/// Amend previous commit
|
|
#[arg(long)]
|
|
amend: bool,
|
|
|
|
/// Stage all changes before committing
|
|
#[arg(short = 'a', long)]
|
|
all: bool,
|
|
|
|
/// Dry run (show message without committing)
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
|
|
/// Use conventional commit format
|
|
#[arg(long, group = "format")]
|
|
conventional: bool,
|
|
|
|
/// Use commitlint format
|
|
#[arg(long, group = "format")]
|
|
commitlint: bool,
|
|
|
|
/// Don't verify commit message
|
|
#[arg(long)]
|
|
no_verify: bool,
|
|
|
|
/// Skip interactive prompts
|
|
#[arg(short = 'y', long)]
|
|
yes: bool,
|
|
|
|
/// Push after committing
|
|
#[arg(long)]
|
|
push: bool,
|
|
|
|
/// Remote to push to
|
|
#[arg(long, default_value = "origin")]
|
|
remote: String,
|
|
}
|
|
|
|
impl CommitCommand {
|
|
pub async fn execute(&self) -> Result<()> {
|
|
// Find git repository
|
|
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
|
|
|
// Load configuration
|
|
let manager = ConfigManager::new()?;
|
|
let config = manager.config();
|
|
let language = manager.get_language().unwrap_or(Language::English);
|
|
let messages = Messages::new(language);
|
|
|
|
// Check for changes
|
|
let status = repo.status_summary()?;
|
|
if status.clean && !self.amend {
|
|
bail!("{}", messages.no_changes());
|
|
}
|
|
|
|
// Determine commit format
|
|
let format = if self.conventional {
|
|
CommitFormat::Conventional
|
|
} else if self.commitlint {
|
|
CommitFormat::Commitlint
|
|
} else {
|
|
config.commit.format
|
|
};
|
|
|
|
// Auto-add if no files are staged and there are unstaged/untracked changes
|
|
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
|
|
println!("{}", messages.auto_stage_changes().yellow());
|
|
repo.stage_all()?;
|
|
println!("{}", messages.staged_all().green());
|
|
|
|
// Re-check status after staging to ensure changes are detected
|
|
let new_status = repo.status_summary()?;
|
|
if new_status.staged == 0 {
|
|
bail!("Failed to stage changes. Please try running 'git add -A' manually.");
|
|
}
|
|
}
|
|
|
|
// Stage all if requested
|
|
if self.all {
|
|
repo.stage_all()?;
|
|
println!("{}", messages.staged_all().green());
|
|
}
|
|
|
|
// Generate or build commit message
|
|
let commit_message = if self.date {
|
|
// Date-based commit
|
|
self.create_date_commit()
|
|
} else if self.manual || self.message.is_some() {
|
|
// Manual commit
|
|
self.create_manual_commit(format)?
|
|
} else if config.commit.auto_generate {
|
|
// AI-generated commit
|
|
self.generate_commit(&repo, format, &messages).await?
|
|
} else {
|
|
// Interactive commit creation
|
|
self.create_interactive_commit(format, &messages).await?
|
|
};
|
|
|
|
// Validate message
|
|
match format {
|
|
CommitFormat::Conventional => {
|
|
crate::utils::validators::validate_conventional_commit(&commit_message)?;
|
|
}
|
|
CommitFormat::Commitlint => {
|
|
crate::utils::validators::validate_commitlint_commit(&commit_message)?;
|
|
}
|
|
}
|
|
|
|
// Show commit preview
|
|
if !self.yes {
|
|
println!("\n{}", "─".repeat(60));
|
|
println!("{}", messages.commit_preview().bold());
|
|
println!("{}", "─".repeat(60));
|
|
println!("{}", commit_message);
|
|
println!("{}", "─".repeat(60));
|
|
|
|
let confirm = Confirm::new()
|
|
.with_prompt(messages.proceed_commit())
|
|
.default(true)
|
|
.interact()?;
|
|
|
|
if !confirm {
|
|
println!("{}", messages.commit_cancelled().yellow());
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
let result = if self.amend {
|
|
if self.dry_run {
|
|
println!("\n{} {}", messages.dry_run(), "- commit not amended.".yellow());
|
|
return Ok(());
|
|
}
|
|
self.amend_commit(&repo, &commit_message)?;
|
|
None
|
|
} else {
|
|
if self.dry_run {
|
|
println!("\n{} {}", messages.dry_run(), "- commit not created.".yellow());
|
|
return Ok(());
|
|
}
|
|
CommitBuilder::new()
|
|
.message(&commit_message)
|
|
.sign(self.sign)
|
|
.execute(&repo)?
|
|
};
|
|
|
|
if let Some(commit_oid) = result {
|
|
println!("{} {}", messages.commit_created().green().bold(), commit_oid.to_string()[..8].to_string().cyan());
|
|
} else {
|
|
println!("{} {}", messages.commit_amended().green().bold(), "successfully");
|
|
}
|
|
|
|
// Push after commit if requested or ask user
|
|
if self.push {
|
|
println!("\n{}", messages.pushing_commit(&self.remote));
|
|
repo.push(&self.remote, "HEAD")?;
|
|
println!("{}", messages.pushed_commit(&self.remote));
|
|
} else if !self.yes && !self.dry_run {
|
|
let should_push = Confirm::new()
|
|
.with_prompt(messages.push_after_commit())
|
|
.default(false)
|
|
.interact()?;
|
|
|
|
if should_push {
|
|
println!("\n{}", messages.pushing_commit(&self.remote));
|
|
repo.push(&self.remote, "HEAD")?;
|
|
println!("{}", messages.pushed_commit(&self.remote));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn create_date_commit(&self) -> String {
|
|
let prefix = self.commit_type.as_deref();
|
|
create_date_commit_message(prefix)
|
|
}
|
|
|
|
fn create_manual_commit(&self, format: CommitFormat) -> Result<String> {
|
|
let description = self.message.clone()
|
|
.ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?;
|
|
|
|
// Try to extract commit type from message if not provided
|
|
let commit_type = if let Some(ref ct) = self.commit_type {
|
|
ct.clone()
|
|
} else {
|
|
// Parse from conventional commit format: "type: description"
|
|
description
|
|
.split(':')
|
|
.next()
|
|
.unwrap_or("feat")
|
|
.trim()
|
|
.to_string()
|
|
};
|
|
|
|
let builder = CommitBuilder::new()
|
|
.commit_type(commit_type)
|
|
.description(description)
|
|
.scope_opt(self.scope.clone())
|
|
.body_opt(self.body.clone())
|
|
.breaking(self.breaking)
|
|
.format(format);
|
|
|
|
builder.build_message()
|
|
}
|
|
|
|
async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat, messages: &Messages) -> Result<String> {
|
|
let manager = ConfigManager::new()?;
|
|
let config = manager.config();
|
|
|
|
// Check if LLM is configured
|
|
let generator = ContentGenerator::new(&config.llm).await
|
|
.context("Failed to initialize LLM. Use --manual for manual commit.")?;
|
|
|
|
println!("{}", messages.ai_analyzing());
|
|
|
|
let language_str = &config.language.output_language;
|
|
let language = Language::from_str(language_str).unwrap_or(Language::English);
|
|
|
|
let generated = if self.yes {
|
|
// Non-interactive mode: generate directly
|
|
generator.generate_commit_from_repo(repo, format, language).await?
|
|
} else {
|
|
// Interactive mode: allow user to review and regenerate
|
|
generator.generate_commit_interactive(repo, format, language).await?
|
|
};
|
|
|
|
Ok(generated.to_conventional())
|
|
}
|
|
|
|
async fn create_interactive_commit(&self, format: CommitFormat, messages: &Messages) -> Result<String> {
|
|
let types = get_commit_types(format == CommitFormat::Commitlint);
|
|
|
|
// Select type
|
|
let type_idx = Select::new()
|
|
.with_prompt(messages.select_commit_type())
|
|
.items(types)
|
|
.interact()?;
|
|
let commit_type = types[type_idx].to_string();
|
|
|
|
// Enter scope (optional)
|
|
let scope: String = Input::new()
|
|
.with_prompt(messages.scope_optional())
|
|
.allow_empty(true)
|
|
.interact_text()?;
|
|
let scope = if scope.is_empty() { None } else { Some(scope) };
|
|
|
|
// Enter description
|
|
let description: String = Input::new()
|
|
.with_prompt(messages.description())
|
|
.interact_text()?;
|
|
|
|
// Breaking change
|
|
let breaking = Confirm::new()
|
|
.with_prompt(messages.breaking_change())
|
|
.default(false)
|
|
.interact()?;
|
|
|
|
// Add body
|
|
let add_body = Confirm::new()
|
|
.with_prompt(messages.add_body())
|
|
.default(false)
|
|
.interact()?;
|
|
|
|
let body = if add_body {
|
|
let body_text = crate::utils::editor::edit_content(messages.enter_commit_body())?;
|
|
if body_text.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(body_text)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Build commit
|
|
let builder = CommitBuilder::new()
|
|
.commit_type(commit_type)
|
|
.description(description)
|
|
.scope_opt(scope)
|
|
.body_opt(body)
|
|
.breaking(breaking)
|
|
.format(format);
|
|
|
|
builder.build_message()
|
|
}
|
|
|
|
fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> {
|
|
use std::process::Command;
|
|
|
|
let mut args = vec!["commit", "--amend", "-m", message];
|
|
|
|
if self.no_verify {
|
|
args.push("--no-verify");
|
|
}
|
|
|
|
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(())
|
|
}
|
|
}
|
|
|
|
// Helper trait for optional builder methods
|
|
trait CommitBuilderExt {
|
|
fn scope_opt(self, scope: Option<String>) -> Self;
|
|
fn body_opt(self, body: Option<String>) -> Self;
|
|
}
|
|
|
|
impl CommitBuilderExt for CommitBuilder {
|
|
fn scope_opt(self, scope: Option<String>) -> Self {
|
|
if let Some(s) = scope {
|
|
self.scope(s)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
fn body_opt(self, body: Option<String>) -> Self {
|
|
if let Some(b) = body {
|
|
self.body(b)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|