Files
QuiCommit/src/commands/commit.rs

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
}
}
}