feat:(first commit)created repository and complete 0.1.0
This commit is contained in:
199
src/commands/changelog.rs
Normal file
199
src/commands/changelog.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use anyhow::{bail, Result};
|
||||
use chrono::Utc;
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::generator::ContentGenerator;
|
||||
use crate::git::find_repo;
|
||||
use crate::git::{changelog::*, CommitInfo, GitRepo};
|
||||
|
||||
/// Generate changelog
|
||||
#[derive(Parser)]
|
||||
pub struct ChangelogCommand {
|
||||
/// Output file path
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Version to generate changelog for
|
||||
#[arg(short, long)]
|
||||
version: Option<String>,
|
||||
|
||||
/// Generate from specific tag
|
||||
#[arg(short, long)]
|
||||
from: Option<String>,
|
||||
|
||||
/// Generate to specific ref
|
||||
#[arg(short, long, default_value = "HEAD")]
|
||||
to: String,
|
||||
|
||||
/// Initialize new changelog file
|
||||
#[arg(short, long)]
|
||||
init: bool,
|
||||
|
||||
/// Generate with AI
|
||||
#[arg(short, long)]
|
||||
generate: bool,
|
||||
|
||||
/// Prepend to existing changelog
|
||||
#[arg(short, long)]
|
||||
prepend: bool,
|
||||
|
||||
/// Include commit hashes
|
||||
#[arg(long)]
|
||||
include_hashes: bool,
|
||||
|
||||
/// Include authors
|
||||
#[arg(long)]
|
||||
include_authors: bool,
|
||||
|
||||
/// Format (keep-a-changelog, github-releases)
|
||||
#[arg(short, long)]
|
||||
format: Option<String>,
|
||||
|
||||
/// Dry run (output to stdout)
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
|
||||
/// Skip interactive prompts
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
}
|
||||
|
||||
impl ChangelogCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
let repo = find_repo(".")?;
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
// Initialize changelog if requested
|
||||
if self.init {
|
||||
let path = self.output.as_ref()
|
||||
.map(|p| p.clone())
|
||||
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
|
||||
|
||||
init_changelog(&path)?;
|
||||
println!("{} Initialized changelog at {:?}", "✓".green(), path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Determine output path
|
||||
let output_path = self.output.as_ref()
|
||||
.map(|p| p.clone())
|
||||
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
|
||||
|
||||
// Determine format
|
||||
let format = match self.format.as_deref() {
|
||||
Some("github") | Some("github-releases") => ChangelogFormat::GitHubReleases,
|
||||
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
|
||||
Some("custom") => ChangelogFormat::Custom,
|
||||
None => ChangelogFormat::KeepAChangelog,
|
||||
Some(f) => bail!("Unknown format: {}. Use: keep-a-changelog, github-releases", f),
|
||||
};
|
||||
|
||||
// Get version
|
||||
let version = if let Some(ref v) = self.version {
|
||||
v.clone()
|
||||
} else if !self.yes {
|
||||
Input::new()
|
||||
.with_prompt("Version")
|
||||
.default("Unreleased".to_string())
|
||||
.interact_text()?
|
||||
} else {
|
||||
"Unreleased".to_string()
|
||||
};
|
||||
|
||||
// Get commits
|
||||
println!("{} Fetching commits...", "→".blue());
|
||||
let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?;
|
||||
|
||||
if commits.is_empty() {
|
||||
bail!("No commits found in the specified range");
|
||||
}
|
||||
|
||||
println!("{} Found {} commits", "✓".green(), commits.len());
|
||||
|
||||
// Generate changelog
|
||||
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) {
|
||||
self.generate_with_ai(&repo, &version, &commits).await?
|
||||
} else {
|
||||
self.generate_with_template(format, &version, &commits)?
|
||||
};
|
||||
|
||||
// Output or write
|
||||
if self.dry_run {
|
||||
println!("\n{}", "─".repeat(60));
|
||||
println!("{}", changelog);
|
||||
println!("{}", "─".repeat(60));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Preview
|
||||
if !self.yes {
|
||||
println!("\n{}", "─".repeat(60));
|
||||
println!("{}", "Changelog preview:".bold());
|
||||
println!("{}", "─".repeat(60));
|
||||
// Show first 20 lines
|
||||
let preview: String = changelog.lines().take(20).collect::<Vec<_>>().join("\n");
|
||||
println!("{}", preview);
|
||||
if changelog.lines().count() > 20 {
|
||||
println!("\n... ({} more lines)", changelog.lines().count() - 20);
|
||||
}
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt(&format!("Write to {:?}?", output_path))
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", "Cancelled.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if self.prepend && output_path.exists() {
|
||||
let existing = std::fs::read_to_string(&output_path)?;
|
||||
let new_content = format!("{}\n{}", changelog, existing);
|
||||
std::fs::write(&output_path, new_content)?;
|
||||
} else {
|
||||
std::fs::write(&output_path, changelog)?;
|
||||
}
|
||||
|
||||
println!("{} Changelog written to {:?}", "✓".green(), output_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_with_ai(
|
||||
&self,
|
||||
repo: &GitRepo,
|
||||
version: &str,
|
||||
commits: &[CommitInfo],
|
||||
) -> Result<String> {
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("{} AI is generating changelog...", "🤖");
|
||||
|
||||
let generator = ContentGenerator::new(&config.llm).await?;
|
||||
generator.generate_changelog_entry(version, commits).await
|
||||
}
|
||||
|
||||
fn generate_with_template(
|
||||
&self,
|
||||
format: ChangelogFormat,
|
||||
version: &str,
|
||||
commits: &[CommitInfo],
|
||||
) -> Result<String> {
|
||||
let generator = ChangelogGenerator::new()
|
||||
.format(format)
|
||||
.include_hashes(self.include_hashes)
|
||||
.include_authors(self.include_authors);
|
||||
|
||||
generator.generate(version, Utc::now(), commits)
|
||||
}
|
||||
}
|
||||
321
src/commands/commit.rs
Normal file
321
src/commands/commit.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
|
||||
use crate::config::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, parse_commit_message};
|
||||
use crate::utils::validators::get_commit_types;
|
||||
|
||||
/// Generate and execute conventional commits
|
||||
#[derive(Parser)]
|
||||
pub struct CommitCommand {
|
||||
/// Commit type
|
||||
#[arg(short, 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(short, 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,
|
||||
}
|
||||
|
||||
impl CommitCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
// Find git repository
|
||||
let repo = find_repo(".")?;
|
||||
|
||||
// Check for changes
|
||||
let status = repo.status_summary()?;
|
||||
if status.clean && !self.amend {
|
||||
bail!("No changes to commit. Working tree is clean.");
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
// Determine commit format
|
||||
let format = if self.conventional {
|
||||
CommitFormat::Conventional
|
||||
} else if self.commitlint {
|
||||
CommitFormat::Commitlint
|
||||
} else {
|
||||
config.commit.format
|
||||
};
|
||||
|
||||
// Stage all if requested
|
||||
if self.all {
|
||||
repo.stage_all()?;
|
||||
println!("{}", "✓ Staged all changes".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 && !self.yes {
|
||||
// AI-generated commit
|
||||
self.generate_commit(&repo, format).await?
|
||||
} else {
|
||||
// Interactive commit creation
|
||||
self.create_interactive_commit(format).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!("{}", "Commit preview:".bold());
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("{}", commit_message);
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt("Do you want to proceed with this commit?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", "Commit cancelled.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if self.dry_run {
|
||||
println!("\n{}", "Dry run - commit not created.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Execute commit
|
||||
let result = if self.amend {
|
||||
self.amend_commit(&repo, &commit_message)?;
|
||||
None
|
||||
} else {
|
||||
Some(repo.commit(&commit_message, self.sign)?)
|
||||
};
|
||||
|
||||
if let Some(commit_oid) = result {
|
||||
println!("{} {}", "✓ Created commit".green().bold(), commit_oid.to_string()[..8].to_string().cyan());
|
||||
} else {
|
||||
println!("{} {}", "✓ Amended commit".green().bold(), "successfully");
|
||||
}
|
||||
|
||||
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 commit_type = self.commit_type.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Commit type required for manual commit. Use -t <type>"))?;
|
||||
|
||||
let description = self.message.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?;
|
||||
|
||||
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) -> 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!("{} AI is analyzing your changes...", "🤖".to_string());
|
||||
|
||||
let generated = if self.yes {
|
||||
generator.generate_commit_from_repo(repo, format).await?
|
||||
} else {
|
||||
generator.generate_commit_interactive(repo, format).await?
|
||||
};
|
||||
|
||||
Ok(generated.to_conventional())
|
||||
}
|
||||
|
||||
async fn create_interactive_commit(&self, format: CommitFormat) -> Result<String> {
|
||||
let types = get_commit_types(format == CommitFormat::Commitlint);
|
||||
|
||||
// Select type
|
||||
let type_idx = Select::new()
|
||||
.with_prompt("Select commit type")
|
||||
.items(types)
|
||||
.interact()?;
|
||||
let commit_type = types[type_idx].to_string();
|
||||
|
||||
// Enter scope (optional)
|
||||
let scope: String = Input::new()
|
||||
.with_prompt("Scope (optional, press Enter to skip)")
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
let scope = if scope.is_empty() { None } else { Some(scope) };
|
||||
|
||||
// Enter description
|
||||
let description: String = Input::new()
|
||||
.with_prompt("Description")
|
||||
.interact_text()?;
|
||||
|
||||
// Breaking change
|
||||
let breaking = Confirm::new()
|
||||
.with_prompt("Is this a breaking change?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
// Add body
|
||||
let add_body = Confirm::new()
|
||||
.with_prompt("Add body to commit?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let body = if add_body {
|
||||
let body_text = crate::utils::editor::edit_content("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 stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to amend commit: {}", stderr);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
518
src/commands/config.rs
Normal file
518
src/commands/config.rs
Normal file
@@ -0,0 +1,518 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{CommitFormat, LlmConfig};
|
||||
|
||||
/// Manage configuration settings
|
||||
#[derive(Parser)]
|
||||
pub struct ConfigCommand {
|
||||
#[command(subcommand)]
|
||||
command: Option<ConfigSubcommand>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ConfigSubcommand {
|
||||
/// Show current configuration
|
||||
Show,
|
||||
|
||||
/// Edit configuration file
|
||||
Edit,
|
||||
|
||||
/// Set configuration value
|
||||
Set {
|
||||
/// Key (e.g., llm.provider, commit.format)
|
||||
key: String,
|
||||
/// Value
|
||||
value: String,
|
||||
},
|
||||
|
||||
/// Get configuration value
|
||||
Get {
|
||||
/// Key
|
||||
key: String,
|
||||
},
|
||||
|
||||
/// Set LLM provider
|
||||
SetLlm {
|
||||
/// Provider (ollama, openai, anthropic)
|
||||
#[arg(value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
},
|
||||
|
||||
/// Set OpenAI API key
|
||||
SetOpenAiKey {
|
||||
/// API key
|
||||
key: String,
|
||||
},
|
||||
|
||||
/// Set Anthropic API key
|
||||
SetAnthropicKey {
|
||||
/// API key
|
||||
key: String,
|
||||
},
|
||||
|
||||
/// Configure Ollama settings
|
||||
SetOllama {
|
||||
/// Ollama server URL
|
||||
#[arg(short, long)]
|
||||
url: Option<String>,
|
||||
/// Model name
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
},
|
||||
|
||||
/// Set commit format
|
||||
SetCommitFormat {
|
||||
/// Format (conventional, commitlint)
|
||||
format: String,
|
||||
},
|
||||
|
||||
/// Set version prefix for tags
|
||||
SetVersionPrefix {
|
||||
/// Prefix (e.g., 'v')
|
||||
prefix: String,
|
||||
},
|
||||
|
||||
/// Set changelog path
|
||||
SetChangelogPath {
|
||||
/// Path
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Reset configuration to defaults
|
||||
Reset {
|
||||
/// Skip confirmation
|
||||
#[arg(short, long)]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// Export configuration
|
||||
Export {
|
||||
/// Output file (defaults to stdout)
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
|
||||
/// Import configuration
|
||||
Import {
|
||||
/// Input file
|
||||
#[arg(short, long)]
|
||||
file: String,
|
||||
},
|
||||
|
||||
/// List available LLM models
|
||||
ListModels,
|
||||
|
||||
/// Test LLM connection
|
||||
TestLlm,
|
||||
}
|
||||
|
||||
impl ConfigCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
match &self.command {
|
||||
Some(ConfigSubcommand::Show) => self.show_config().await,
|
||||
Some(ConfigSubcommand::Edit) => self.edit_config().await,
|
||||
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value).await,
|
||||
Some(ConfigSubcommand::Get { key }) => self.get_value(key).await,
|
||||
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key).await,
|
||||
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key).await,
|
||||
Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format).await,
|
||||
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await,
|
||||
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await,
|
||||
Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await,
|
||||
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await,
|
||||
Some(ConfigSubcommand::Import { file }) => self.import_config(file).await,
|
||||
Some(ConfigSubcommand::ListModels) => self.list_models().await,
|
||||
Some(ConfigSubcommand::TestLlm) => self.test_llm().await,
|
||||
None => self.show_config().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn show_config(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("{}", "\nQuicCommit Configuration".bold());
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
println!("\n{}", "General:".bold());
|
||||
println!(" Config file: {}", manager.path().display());
|
||||
println!(" Default profile: {}",
|
||||
config.default_profile.as_deref().unwrap_or("(none)").cyan());
|
||||
println!(" Profiles: {}", config.profiles.len());
|
||||
|
||||
println!("\n{}", "LLM Configuration:".bold());
|
||||
println!(" Provider: {}", config.llm.provider.cyan());
|
||||
println!(" Max tokens: {}", config.llm.max_tokens);
|
||||
println!(" Temperature: {}", config.llm.temperature);
|
||||
println!(" Timeout: {}s", config.llm.timeout);
|
||||
|
||||
match config.llm.provider.as_str() {
|
||||
"ollama" => {
|
||||
println!(" URL: {}", config.llm.ollama.url);
|
||||
println!(" Model: {}", config.llm.ollama.model.cyan());
|
||||
}
|
||||
"openai" => {
|
||||
println!(" Model: {}", config.llm.openai.model.cyan());
|
||||
println!(" API key: {}",
|
||||
if config.llm.openai.api_key.is_some() { "✓ set".green() } else { "✗ not set".red() });
|
||||
}
|
||||
"anthropic" => {
|
||||
println!(" Model: {}", config.llm.anthropic.model.cyan());
|
||||
println!(" API key: {}",
|
||||
if config.llm.anthropic.api_key.is_some() { "✓ set".green() } else { "✗ not set".red() });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
println!("\n{}", "Commit Configuration:".bold());
|
||||
println!(" Format: {}", config.commit.format.to_string().cyan());
|
||||
println!(" Auto-generate: {}", if config.commit.auto_generate { "yes".green() } else { "no".red() });
|
||||
println!(" GPG sign: {}", if config.commit.gpg_sign { "yes".green() } else { "no".red() });
|
||||
println!(" Max subject length: {}", config.commit.max_subject_length);
|
||||
|
||||
println!("\n{}", "Tag Configuration:".bold());
|
||||
println!(" Version prefix: '{}'", config.tag.version_prefix);
|
||||
println!(" Auto-generate: {}", if config.tag.auto_generate { "yes".green() } else { "no".red() });
|
||||
println!(" GPG sign: {}", if config.tag.gpg_sign { "yes".green() } else { "no".red() });
|
||||
println!(" Include changelog: {}", if config.tag.include_changelog { "yes".green() } else { "no".red() });
|
||||
|
||||
println!("\n{}", "Changelog Configuration:".bold());
|
||||
println!(" Path: {}", config.changelog.path);
|
||||
println!(" Auto-generate: {}", if config.changelog.auto_generate { "yes".green() } else { "no".red() });
|
||||
println!(" Include hashes: {}", if config.changelog.include_hashes { "yes".green() } else { "no".red() });
|
||||
println!(" Include authors: {}", if config.changelog.include_authors { "yes".green() } else { "no".red() });
|
||||
println!(" Group by type: {}", if config.changelog.group_by_type { "yes".green() } else { "no".red() });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_config(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
crate::utils::editor::edit_file(manager.path())?;
|
||||
println!("{} Configuration updated", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_value(&self, key: &str, value: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
match key {
|
||||
"llm.provider" => manager.set_llm_provider(value.to_string()),
|
||||
"llm.max_tokens" => {
|
||||
let tokens: u32 = value.parse()?;
|
||||
manager.config_mut().llm.max_tokens = tokens;
|
||||
}
|
||||
"llm.temperature" => {
|
||||
let temp: f32 = value.parse()?;
|
||||
manager.config_mut().llm.temperature = temp;
|
||||
}
|
||||
"llm.timeout" => {
|
||||
let timeout: u64 = value.parse()?;
|
||||
manager.config_mut().llm.timeout = timeout;
|
||||
}
|
||||
"commit.format" => {
|
||||
let format = match value {
|
||||
"conventional" => CommitFormat::Conventional,
|
||||
"commitlint" => CommitFormat::Commitlint,
|
||||
_ => bail!("Invalid format: {}. Use: conventional, commitlint", value),
|
||||
};
|
||||
manager.set_commit_format(format);
|
||||
}
|
||||
"commit.auto_generate" => {
|
||||
manager.set_auto_generate_commits(value == "true");
|
||||
}
|
||||
"tag.version_prefix" => manager.set_version_prefix(value.to_string()),
|
||||
"changelog.path" => manager.set_changelog_path(value.to_string()),
|
||||
_ => bail!("Unknown configuration key: {}", key),
|
||||
}
|
||||
|
||||
manager.save()?;
|
||||
println!("{} Set {} = {}", "✓".green(), key.cyan(), value);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_value(&self, key: &str) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
let value = match key {
|
||||
"llm.provider" => &config.llm.provider,
|
||||
"llm.max_tokens" => return Ok(println!("{}", config.llm.max_tokens)),
|
||||
"llm.temperature" => return Ok(println!("{}", config.llm.temperature)),
|
||||
"llm.timeout" => return Ok(println!("{}", config.llm.timeout)),
|
||||
"commit.format" => return Ok(println!("{}", config.commit.format)),
|
||||
"tag.version_prefix" => &config.tag.version_prefix,
|
||||
"changelog.path" => &config.changelog.path,
|
||||
_ => bail!("Unknown configuration key: {}", key),
|
||||
};
|
||||
|
||||
println!("{}", value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_llm(&self, provider: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
let provider = if let Some(p) = provider {
|
||||
p.to_string()
|
||||
} else {
|
||||
let providers = vec!["ollama", "openai", "anthropic"];
|
||||
let idx = Select::new()
|
||||
.with_prompt("Select LLM provider")
|
||||
.items(&providers)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
providers[idx].to_string()
|
||||
};
|
||||
|
||||
manager.set_llm_provider(provider.clone());
|
||||
|
||||
// Configure provider-specific settings
|
||||
match provider.as_str() {
|
||||
"openai" => {
|
||||
let api_key: String = Input::new()
|
||||
.with_prompt("OpenAI API key")
|
||||
.interact_text()?;
|
||||
manager.set_openai_api_key(api_key);
|
||||
|
||||
let model: String = Input::new()
|
||||
.with_prompt("Model")
|
||||
.default("gpt-4".to_string())
|
||||
.interact_text()?;
|
||||
manager.config_mut().llm.openai.model = model;
|
||||
}
|
||||
"anthropic" => {
|
||||
let api_key: String = Input::new()
|
||||
.with_prompt("Anthropic API key")
|
||||
.interact_text()?;
|
||||
manager.set_anthropic_api_key(api_key);
|
||||
}
|
||||
"ollama" => {
|
||||
let url: String = Input::new()
|
||||
.with_prompt("Ollama URL")
|
||||
.default("http://localhost:11434".to_string())
|
||||
.interact_text()?;
|
||||
manager.config_mut().llm.ollama.url = url;
|
||||
|
||||
let model: String = Input::new()
|
||||
.with_prompt("Model")
|
||||
.default("llama2".to_string())
|
||||
.interact_text()?;
|
||||
manager.config_mut().llm.ollama.model = model;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
manager.save()?;
|
||||
println!("{} Set LLM provider to {}", "✓".green(), provider.cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_openai_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
manager.set_openai_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} OpenAI API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_anthropic_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
manager.set_anthropic_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Anthropic API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
if let Some(u) = url {
|
||||
manager.config_mut().llm.ollama.url = u.to_string();
|
||||
}
|
||||
if let Some(m) = model {
|
||||
manager.config_mut().llm.ollama.model = m.to_string();
|
||||
}
|
||||
|
||||
manager.save()?;
|
||||
println!("{} Ollama configuration updated", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_commit_format(&self, format: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
let format = match format {
|
||||
"conventional" => CommitFormat::Conventional,
|
||||
"commitlint" => CommitFormat::Commitlint,
|
||||
_ => bail!("Invalid format: {}. Use: conventional, commitlint", format),
|
||||
};
|
||||
|
||||
manager.set_commit_format(format);
|
||||
manager.save()?;
|
||||
println!("{} Set commit format to {}", "✓".green(), format.to_string().cyan());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_version_prefix(&self, prefix: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
manager.set_version_prefix(prefix.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Set version prefix to '{}'", "✓".green(), prefix);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_changelog_path(&self, path: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
manager.set_changelog_path(path.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Set changelog path to {}", "✓".green(), path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reset(&self, force: bool) -> Result<()> {
|
||||
if !force {
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt("Are you sure you want to reset all configuration?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", "Cancelled.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = ConfigManager::new()?;
|
||||
manager.reset();
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Configuration reset to defaults", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn export_config(&self, output: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
let toml = manager.export()?;
|
||||
|
||||
if let Some(path) = output {
|
||||
std::fs::write(path, toml)?;
|
||||
println!("{} Configuration exported to {}", "✓".green(), path);
|
||||
} else {
|
||||
println!("{}", toml);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import_config(&self, file: &str) -> Result<()> {
|
||||
let toml = std::fs::read_to_string(file)?;
|
||||
|
||||
let mut manager = ConfigManager::new()?;
|
||||
manager.import(&toml)?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Configuration imported from {}", "✓".green(), file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
match config.llm.provider.as_str() {
|
||||
"ollama" => {
|
||||
let client = crate::llm::OllamaClient::new(
|
||||
&config.llm.ollama.url,
|
||||
&config.llm.ollama.model,
|
||||
);
|
||||
|
||||
println!("Fetching available models from Ollama...");
|
||||
match client.list_models().await {
|
||||
Ok(models) => {
|
||||
println!("\n{}", "Available models:".bold());
|
||||
for model in models {
|
||||
let marker = if model == config.llm.ollama.model { "●".green() } else { "○".dimmed() };
|
||||
println!("{} {}", marker, model);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{} Failed to fetch models: {}", "✗".red(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
"openai" => {
|
||||
if let Some(ref key) = config.llm.openai.api_key {
|
||||
let client = crate::llm::OpenAiClient::new(
|
||||
&config.llm.openai.base_url,
|
||||
key,
|
||||
&config.llm.openai.model,
|
||||
)?;
|
||||
|
||||
println!("Fetching available models from OpenAI...");
|
||||
match client.list_models().await {
|
||||
Ok(models) => {
|
||||
println!("\n{}", "Available models:".bold());
|
||||
for model in models {
|
||||
let marker = if model == config.llm.openai.model { "●".green() } else { "○".dimmed() };
|
||||
println!("{} {}", marker, model);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{} Failed to fetch models: {}", "✗".red(), e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bail!("OpenAI API key not configured");
|
||||
}
|
||||
}
|
||||
provider => {
|
||||
println!("Listing models not supported for provider: {}", provider);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn test_llm(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("Testing LLM connection ({})...", config.llm.provider.cyan());
|
||||
|
||||
match crate::llm::LlmClient::from_config(&config.llm).await {
|
||||
Ok(client) => {
|
||||
if client.is_available().await {
|
||||
println!("{} LLM connection successful!", "✓".green());
|
||||
|
||||
// Test generation
|
||||
println!("Testing generation...");
|
||||
match client.generate_commit_message("test", crate::config::CommitFormat::Conventional).await {
|
||||
Ok(response) => {
|
||||
println!("{} Generation test passed", "✓".green());
|
||||
println!("Response: {}", response.description.dimmed());
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{} Generation test failed: {}", "✗".red(), e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("{} LLM provider is not available", "✗".red());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{} Failed to initialize LLM: {}", "✗".red(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
270
src/commands/init.rs
Normal file
270
src/commands/init.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
|
||||
use crate::config::{GitProfile};
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::profile::{GpgConfig, SshConfig};
|
||||
use crate::utils::validators::validate_email;
|
||||
|
||||
/// Initialize quicommit configuration
|
||||
#[derive(Parser)]
|
||||
pub struct InitCommand {
|
||||
/// Skip interactive setup
|
||||
#[arg(short, long)]
|
||||
yes: bool,
|
||||
|
||||
/// Reset existing configuration
|
||||
#[arg(long)]
|
||||
reset: bool,
|
||||
}
|
||||
|
||||
impl InitCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
println!("{}", "🚀 Initializing QuicCommit...".bold().cyan());
|
||||
|
||||
let config_path = crate::config::AppConfig::default_path()?;
|
||||
|
||||
// Check if config already exists
|
||||
if config_path.exists() && !self.reset {
|
||||
if !self.yes {
|
||||
let overwrite = Confirm::new()
|
||||
.with_prompt("Configuration already exists. Overwrite?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !overwrite {
|
||||
println!("{}", "Initialization cancelled.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = if self.reset {
|
||||
ConfigManager::new()?
|
||||
} else {
|
||||
ConfigManager::new().or_else(|_| Ok::<_, anyhow::Error>(ConfigManager::default()))?
|
||||
};
|
||||
|
||||
if self.yes {
|
||||
// Quick setup with defaults
|
||||
self.quick_setup(&mut manager).await?;
|
||||
} else {
|
||||
// Interactive setup
|
||||
self.interactive_setup(&mut manager).await?;
|
||||
}
|
||||
|
||||
manager.save()?;
|
||||
|
||||
println!("{}", "✅ QuicCommit initialized successfully!".bold().green());
|
||||
println!("\nConfig file: {}", config_path.display());
|
||||
println!("\nNext steps:");
|
||||
println!(" 1. Create a profile: {}", "quicommit profile add".cyan());
|
||||
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
|
||||
println!(" 3. Start committing: {}", "quicommit commit".cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> {
|
||||
// Try to get git user info
|
||||
let git_config = git2::Config::open_default()?;
|
||||
|
||||
let user_name = git_config.get_string("user.name").unwrap_or_else(|_| "User".to_string());
|
||||
let user_email = git_config.get_string("user.email").unwrap_or_else(|_| "user@example.com".to_string());
|
||||
|
||||
let profile = GitProfile::new(
|
||||
"default".to_string(),
|
||||
user_name,
|
||||
user_email,
|
||||
);
|
||||
|
||||
manager.add_profile("default".to_string(), profile)?;
|
||||
manager.set_default_profile(Some("default".to_string()))?;
|
||||
|
||||
// Set default LLM to Ollama
|
||||
manager.set_llm_provider("ollama".to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn interactive_setup(&self, manager: &mut ConfigManager) -> Result<()> {
|
||||
println!("\n{}", "Let's set up your first profile:".bold());
|
||||
|
||||
// Profile name
|
||||
let profile_name: String = Input::new()
|
||||
.with_prompt("Profile name")
|
||||
.default("personal".to_string())
|
||||
.interact_text()?;
|
||||
|
||||
// User info
|
||||
let git_config = git2::Config::open_default().ok();
|
||||
|
||||
let default_name = git_config.as_ref()
|
||||
.and_then(|c| c.get_string("user.name").ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let default_email = git_config.as_ref()
|
||||
.and_then(|c| c.get_string("user.email").ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let user_name: String = Input::new()
|
||||
.with_prompt("Git user name")
|
||||
.default(default_name)
|
||||
.interact_text()?;
|
||||
|
||||
let user_email: String = Input::new()
|
||||
.with_prompt("Git user email")
|
||||
.default(default_email)
|
||||
.validate_with(|input: &String| {
|
||||
validate_email(input).map_err(|e| e.to_string())
|
||||
})
|
||||
.interact_text()?;
|
||||
|
||||
let description: String = Input::new()
|
||||
.with_prompt("Profile description (optional)")
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
|
||||
let is_work = Confirm::new()
|
||||
.with_prompt("Is this a work profile?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let organization = if is_work {
|
||||
Some(Input::new()
|
||||
.with_prompt("Organization/Company name")
|
||||
.interact_text()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// SSH configuration
|
||||
let setup_ssh = Confirm::new()
|
||||
.with_prompt("Configure SSH key?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let ssh_config = if setup_ssh {
|
||||
Some(self.setup_ssh_interactive().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// GPG configuration
|
||||
let setup_gpg = Confirm::new()
|
||||
.with_prompt("Configure GPG signing?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let gpg_config = if setup_gpg {
|
||||
Some(self.setup_gpg_interactive().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create profile
|
||||
let mut profile = GitProfile::new(
|
||||
profile_name.clone(),
|
||||
user_name,
|
||||
user_email,
|
||||
);
|
||||
|
||||
if !description.is_empty() {
|
||||
profile.description = Some(description);
|
||||
}
|
||||
|
||||
profile.is_work = is_work;
|
||||
profile.organization = organization;
|
||||
profile.ssh = ssh_config;
|
||||
profile.gpg = gpg_config;
|
||||
|
||||
manager.add_profile(profile_name.clone(), profile)?;
|
||||
manager.set_default_profile(Some(profile_name))?;
|
||||
|
||||
// LLM provider selection
|
||||
println!("\n{}", "Select your preferred LLM provider:".bold());
|
||||
let providers = vec!["Ollama (local)", "OpenAI", "Anthropic Claude"];
|
||||
let provider_idx = Select::new()
|
||||
.items(&providers)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
let provider = match provider_idx {
|
||||
0 => "ollama",
|
||||
1 => "openai",
|
||||
2 => "anthropic",
|
||||
_ => "ollama",
|
||||
};
|
||||
|
||||
manager.set_llm_provider(provider.to_string());
|
||||
|
||||
// Configure API key if needed
|
||||
if provider == "openai" {
|
||||
let api_key: String = Input::new()
|
||||
.with_prompt("OpenAI API key")
|
||||
.interact_text()?;
|
||||
manager.set_openai_api_key(api_key);
|
||||
} else if provider == "anthropic" {
|
||||
let api_key: String = Input::new()
|
||||
.with_prompt("Anthropic API key")
|
||||
.interact_text()?;
|
||||
manager.set_anthropic_api_key(api_key);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_ssh_interactive(&self) -> Result<SshConfig> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let ssh_dir = dirs::home_dir()
|
||||
.map(|h| h.join(".ssh"))
|
||||
.unwrap_or_else(|| PathBuf::from("~/.ssh"));
|
||||
|
||||
let key_path: String = Input::new()
|
||||
.with_prompt("SSH private key path")
|
||||
.default(ssh_dir.join("id_rsa").display().to_string())
|
||||
.interact_text()?;
|
||||
|
||||
let has_passphrase = Confirm::new()
|
||||
.with_prompt("Does this key have a passphrase?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let passphrase = if has_passphrase {
|
||||
Some(crate::utils::password_input("SSH key passphrase")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(SshConfig {
|
||||
private_key_path: Some(PathBuf::from(key_path)),
|
||||
public_key_path: None,
|
||||
passphrase,
|
||||
agent_forwarding: false,
|
||||
ssh_command: None,
|
||||
known_hosts_file: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn setup_gpg_interactive(&self) -> Result<GpgConfig> {
|
||||
let key_id: String = Input::new()
|
||||
.with_prompt("GPG key ID")
|
||||
.interact_text()?;
|
||||
|
||||
let use_agent = Confirm::new()
|
||||
.with_prompt("Use GPG agent?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
Ok(GpgConfig {
|
||||
key_id,
|
||||
program: "gpg".to_string(),
|
||||
home_dir: None,
|
||||
passphrase: None,
|
||||
use_agent,
|
||||
})
|
||||
}
|
||||
}
|
||||
6
src/commands/mod.rs
Normal file
6
src/commands/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod changelog;
|
||||
pub mod commit;
|
||||
pub mod config;
|
||||
pub mod init;
|
||||
pub mod profile;
|
||||
pub mod tag;
|
||||
492
src/commands/profile.rs
Normal file
492
src/commands/profile.rs
Normal file
@@ -0,0 +1,492 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{GitProfile};
|
||||
use crate::config::profile::{GpgConfig, SshConfig};
|
||||
use crate::git::find_repo;
|
||||
use crate::utils::validators::validate_profile_name;
|
||||
|
||||
/// Manage Git profiles
|
||||
#[derive(Parser)]
|
||||
pub struct ProfileCommand {
|
||||
#[command(subcommand)]
|
||||
command: Option<ProfileSubcommand>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ProfileSubcommand {
|
||||
/// Add a new profile
|
||||
Add,
|
||||
|
||||
/// Remove a profile
|
||||
Remove {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// List all profiles
|
||||
List,
|
||||
|
||||
/// Show profile details
|
||||
Show {
|
||||
/// Profile name
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Edit a profile
|
||||
Edit {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Set default profile
|
||||
SetDefault {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Set profile for current repository
|
||||
SetRepo {
|
||||
/// Profile name
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Apply profile to current repository
|
||||
Apply {
|
||||
/// Profile name (uses default if not specified)
|
||||
name: Option<String>,
|
||||
|
||||
/// Apply globally instead of to current repo
|
||||
#[arg(short, long)]
|
||||
global: bool,
|
||||
},
|
||||
|
||||
/// Switch between profiles interactively
|
||||
Switch,
|
||||
|
||||
/// Copy/duplicate a profile
|
||||
Copy {
|
||||
/// Source profile name
|
||||
from: String,
|
||||
|
||||
/// New profile name
|
||||
to: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProfileCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
match &self.command {
|
||||
Some(ProfileSubcommand::Add) => self.add_profile().await,
|
||||
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name).await,
|
||||
Some(ProfileSubcommand::List) => self.list_profiles().await,
|
||||
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref()).await,
|
||||
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name).await,
|
||||
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name).await,
|
||||
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name).await,
|
||||
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global).await,
|
||||
Some(ProfileSubcommand::Switch) => self.switch_profile().await,
|
||||
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).await,
|
||||
None => self.list_profiles().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_profile(&self) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
println!("{}", "\nAdd new profile".bold());
|
||||
println!("{}", "─".repeat(40));
|
||||
|
||||
let name: String = Input::new()
|
||||
.with_prompt("Profile name")
|
||||
.validate_with(|input: &String| {
|
||||
validate_profile_name(input).map_err(|e| e.to_string())
|
||||
})
|
||||
.interact_text()?;
|
||||
|
||||
if manager.has_profile(&name) {
|
||||
bail!("Profile '{}' already exists", name);
|
||||
}
|
||||
|
||||
let user_name: String = Input::new()
|
||||
.with_prompt("Git user name")
|
||||
.interact_text()?;
|
||||
|
||||
let user_email: String = Input::new()
|
||||
.with_prompt("Git user email")
|
||||
.validate_with(|input: &String| {
|
||||
crate::utils::validators::validate_email(input).map_err(|e| e.to_string())
|
||||
})
|
||||
.interact_text()?;
|
||||
|
||||
let description: String = Input::new()
|
||||
.with_prompt("Description (optional)")
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
|
||||
let is_work = Confirm::new()
|
||||
.with_prompt("Is this a work profile?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let organization = if is_work {
|
||||
Some(Input::new()
|
||||
.with_prompt("Organization")
|
||||
.interact_text()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut profile = GitProfile::new(name.clone(), user_name, user_email);
|
||||
|
||||
if !description.is_empty() {
|
||||
profile.description = Some(description);
|
||||
}
|
||||
profile.is_work = is_work;
|
||||
profile.organization = organization;
|
||||
|
||||
// SSH configuration
|
||||
let setup_ssh = Confirm::new()
|
||||
.with_prompt("Configure SSH key?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if setup_ssh {
|
||||
profile.ssh = Some(self.setup_ssh_interactive().await?);
|
||||
}
|
||||
|
||||
// GPG configuration
|
||||
let setup_gpg = Confirm::new()
|
||||
.with_prompt("Configure GPG signing?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if setup_gpg {
|
||||
profile.gpg = Some(self.setup_gpg_interactive().await?);
|
||||
}
|
||||
|
||||
manager.add_profile(name.clone(), profile)?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Profile '{}' added successfully", "✓".green(), name.cyan());
|
||||
|
||||
// Offer to set as default
|
||||
if manager.default_profile().is_none() {
|
||||
let set_default = Confirm::new()
|
||||
.with_prompt("Set as default profile?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if set_default {
|
||||
manager.set_default_profile(Some(name.clone()))?;
|
||||
manager.save()?;
|
||||
println!("{} Set '{}' as default profile", "✓".green(), name.cyan());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_profile(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
if !manager.has_profile(name) {
|
||||
bail!("Profile '{}' not found", name);
|
||||
}
|
||||
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt(&format!("Are you sure you want to remove profile '{}'?", name))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", "Cancelled.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
manager.remove_profile(name)?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Profile '{}' removed", "✓".green(), name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_profiles(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
|
||||
let profiles = manager.list_profiles();
|
||||
|
||||
if profiles.is_empty() {
|
||||
println!("{}", "No profiles configured.".yellow());
|
||||
println!("Run {} to create one.", "quicommit profile add".cyan());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let default = manager.default_profile_name();
|
||||
|
||||
println!("{}", "\nConfigured profiles:".bold());
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
for name in profiles {
|
||||
let profile = manager.get_profile(name).unwrap();
|
||||
let is_default = default.map(|d| d == name).unwrap_or(false);
|
||||
|
||||
let marker = if is_default { "●".green() } else { "○".dimmed() };
|
||||
let work_marker = if profile.is_work { " [work]".yellow() } else { "".normal() };
|
||||
|
||||
println!("{} {}{}", marker, name.cyan().bold(), work_marker);
|
||||
println!(" {} <{}>", profile.user_name, profile.user_email);
|
||||
|
||||
if let Some(ref desc) = profile.description {
|
||||
println!(" {}", desc.dimmed());
|
||||
}
|
||||
|
||||
if profile.has_ssh() {
|
||||
println!(" {} SSH configured", "🔑".to_string().dimmed());
|
||||
}
|
||||
if profile.has_gpg() {
|
||||
println!(" {} GPG configured", "🔒".to_string().dimmed());
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_profile(&self, name: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
|
||||
let profile = if let Some(n) = name {
|
||||
manager.get_profile(n)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?
|
||||
} else {
|
||||
manager.default_profile()
|
||||
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
|
||||
};
|
||||
|
||||
println!("{}", format!("\nProfile: {}", profile.name).bold());
|
||||
println!("{}", "─".repeat(40));
|
||||
println!("User name: {}", profile.user_name);
|
||||
println!("User email: {}", profile.user_email);
|
||||
|
||||
if let Some(ref desc) = profile.description {
|
||||
println!("Description: {}", desc);
|
||||
}
|
||||
|
||||
println!("Work profile: {}", if profile.is_work { "yes".yellow() } else { "no".normal() });
|
||||
|
||||
if let Some(ref org) = profile.organization {
|
||||
println!("Organization: {}", org);
|
||||
}
|
||||
|
||||
if let Some(ref ssh) = profile.ssh {
|
||||
println!("\n{}", "SSH Configuration:".bold());
|
||||
if let Some(ref path) = ssh.private_key_path {
|
||||
println!(" Private key: {:?}", path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref gpg) = profile.gpg {
|
||||
println!("\n{}", "GPG Configuration:".bold());
|
||||
println!(" Key ID: {}", gpg.key_id);
|
||||
println!(" Program: {}", gpg.program);
|
||||
println!(" Use agent: {}", if gpg.use_agent { "yes" } else { "no" });
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_profile(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
let profile = manager.get_profile(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?
|
||||
.clone();
|
||||
|
||||
println!("{}", format!("\nEditing profile: {}", name).bold());
|
||||
println!("{}", "─".repeat(40));
|
||||
|
||||
let user_name: String = Input::new()
|
||||
.with_prompt("Git user name")
|
||||
.default(profile.user_name.clone())
|
||||
.interact_text()?;
|
||||
|
||||
let user_email: String = Input::new()
|
||||
.with_prompt("Git user email")
|
||||
.default(profile.user_email.clone())
|
||||
.validate_with(|input: &String| {
|
||||
crate::utils::validators::validate_email(input).map_err(|e| e.to_string())
|
||||
})
|
||||
.interact_text()?;
|
||||
|
||||
let mut new_profile = GitProfile::new(name.to_string(), user_name, user_email);
|
||||
new_profile.description = profile.description;
|
||||
new_profile.is_work = profile.is_work;
|
||||
new_profile.organization = profile.organization;
|
||||
new_profile.ssh = profile.ssh;
|
||||
new_profile.gpg = profile.gpg;
|
||||
|
||||
manager.update_profile(name, new_profile)?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Profile '{}' updated", "✓".green(), name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_default(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
manager.set_default_profile(Some(name.to_string()))?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Set '{}' as default profile", "✓".green(), name.cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_repo(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
let repo = find_repo(".")?;
|
||||
|
||||
let repo_path = repo.path().to_string_lossy().to_string();
|
||||
|
||||
manager.set_repo_profile(repo_path, name.to_string())?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Set '{}' for current repository", "✓".green(), name.cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
|
||||
let profile = if let Some(n) = name {
|
||||
manager.get_profile(n)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?
|
||||
.clone()
|
||||
} else {
|
||||
manager.default_profile()
|
||||
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
|
||||
.clone()
|
||||
};
|
||||
|
||||
if global {
|
||||
profile.apply_global()?;
|
||||
println!("{} Applied profile '{}' globally", "✓".green(), profile.name.cyan());
|
||||
} else {
|
||||
let repo = find_repo(".")?;
|
||||
profile.apply_to_repo(repo.inner())?;
|
||||
println!("{} Applied profile '{}' to current repository", "✓".green(), profile.name.cyan());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn switch_profile(&self) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
let profiles: Vec<String> = manager.list_profiles()
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
if profiles.is_empty() {
|
||||
bail!("No profiles configured");
|
||||
}
|
||||
|
||||
let default = manager.default_profile_name().map(|s| s.as_str());
|
||||
let default_idx = default
|
||||
.and_then(|d| profiles.iter().position(|p| p == d))
|
||||
.unwrap_or(0);
|
||||
|
||||
let selection = Select::new()
|
||||
.with_prompt("Select profile to switch to")
|
||||
.items(&profiles)
|
||||
.default(default_idx)
|
||||
.interact()?;
|
||||
|
||||
let selected = &profiles[selection];
|
||||
|
||||
manager.set_default_profile(Some(selected.clone()))?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Switched to profile '{}'", "✓".green(), selected.cyan());
|
||||
|
||||
// Offer to apply to current repo
|
||||
if find_repo(".").is_ok() {
|
||||
let apply = Confirm::new()
|
||||
.with_prompt("Apply to current repository?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if apply {
|
||||
self.apply_profile(Some(selected), false).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_profile(&self, from: &str, to: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
|
||||
let source = manager.get_profile(from)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))?
|
||||
.clone();
|
||||
|
||||
validate_profile_name(to)?;
|
||||
|
||||
let mut new_profile = source.clone();
|
||||
new_profile.name = to.to_string();
|
||||
|
||||
manager.add_profile(to.to_string(), new_profile)?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Copied profile '{}' to '{}'", "✓".green(), from, to.cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_ssh_interactive(&self) -> Result<SshConfig> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let ssh_dir = dirs::home_dir()
|
||||
.map(|h| h.join(".ssh"))
|
||||
.unwrap_or_else(|| PathBuf::from("~/.ssh"));
|
||||
|
||||
let key_path: String = Input::new()
|
||||
.with_prompt("SSH private key path")
|
||||
.default(ssh_dir.join("id_rsa").display().to_string())
|
||||
.interact_text()?;
|
||||
|
||||
Ok(SshConfig {
|
||||
private_key_path: Some(PathBuf::from(key_path)),
|
||||
public_key_path: None,
|
||||
passphrase: None,
|
||||
agent_forwarding: false,
|
||||
ssh_command: None,
|
||||
known_hosts_file: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn setup_gpg_interactive(&self) -> Result<GpgConfig> {
|
||||
let key_id: String = Input::new()
|
||||
.with_prompt("GPG key ID")
|
||||
.interact_text()?;
|
||||
|
||||
Ok(GpgConfig {
|
||||
key_id,
|
||||
program: "gpg".to_string(),
|
||||
home_dir: None,
|
||||
passphrase: None,
|
||||
use_agent: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
307
src/commands/tag.rs
Normal file
307
src/commands/tag.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use semver::Version;
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::git::{find_repo, GitRepo};
|
||||
use crate::generator::ContentGenerator;
|
||||
use crate::git::tag::{
|
||||
bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump,
|
||||
};
|
||||
|
||||
/// Generate and create Git tags
|
||||
#[derive(Parser)]
|
||||
pub struct TagCommand {
|
||||
/// Tag name
|
||||
#[arg(short, long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Semantic version bump
|
||||
#[arg(short, long, value_name = "TYPE")]
|
||||
bump: Option<String>,
|
||||
|
||||
/// Tag message
|
||||
#[arg(short, long)]
|
||||
message: Option<String>,
|
||||
|
||||
/// Generate message with AI
|
||||
#[arg(short, long)]
|
||||
generate: bool,
|
||||
|
||||
/// Sign the tag
|
||||
#[arg(short = 'S', long)]
|
||||
sign: bool,
|
||||
|
||||
/// Create lightweight tag (no annotation)
|
||||
#[arg(short, long)]
|
||||
lightweight: bool,
|
||||
|
||||
/// Force overwrite existing tag
|
||||
#[arg(short, long)]
|
||||
force: bool,
|
||||
|
||||
/// Push tag to remote after creation
|
||||
#[arg(short, long)]
|
||||
push: bool,
|
||||
|
||||
/// Remote to push to
|
||||
#[arg(short, long, default_value = "origin")]
|
||||
remote: String,
|
||||
|
||||
/// Dry run
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
|
||||
/// Skip interactive prompts
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
}
|
||||
|
||||
impl TagCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
let repo = find_repo(".")?;
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
// Determine tag name
|
||||
let tag_name = if let Some(name) = &self.name {
|
||||
name.clone()
|
||||
} else if let Some(bump_str) = &self.bump {
|
||||
// Calculate bumped version
|
||||
let prefix = &config.tag.version_prefix;
|
||||
let latest = get_latest_version(&repo, prefix)?
|
||||
.unwrap_or_else(|| Version::new(0, 0, 0));
|
||||
|
||||
let bump = VersionBump::from_str(bump_str)?;
|
||||
let new_version = bump_version(&latest, bump, None);
|
||||
|
||||
format!("{}{}", prefix, new_version)
|
||||
} else {
|
||||
// Interactive mode
|
||||
self.select_version_interactive(&repo, &config.tag.version_prefix).await?
|
||||
};
|
||||
|
||||
// Validate tag name (if it looks like a version)
|
||||
if tag_name.starts_with('v') || tag_name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
|
||||
let version_str = tag_name.trim_start_matches('v');
|
||||
if let Err(e) = crate::utils::validators::validate_semver(version_str) {
|
||||
println!("{}: {}", "Warning".yellow(), e);
|
||||
|
||||
if !self.yes {
|
||||
let proceed = Confirm::new()
|
||||
.with_prompt("Proceed with this tag name anyway?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if !proceed {
|
||||
bail!("Tag creation cancelled");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate or get tag message
|
||||
let message = if self.lightweight {
|
||||
None
|
||||
} else if let Some(msg) = &self.message {
|
||||
Some(msg.clone())
|
||||
} else if self.generate || (config.tag.auto_generate && !self.yes) {
|
||||
Some(self.generate_tag_message(&repo, &tag_name).await?)
|
||||
} else if !self.yes {
|
||||
Some(self.input_message_interactive(&tag_name)?)
|
||||
} else {
|
||||
Some(format!("Release {}", tag_name))
|
||||
};
|
||||
|
||||
// Show preview
|
||||
println!("\n{}", "─".repeat(60));
|
||||
println!("{}", "Tag preview:".bold());
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("Name: {}", tag_name.cyan());
|
||||
if let Some(ref msg) = message {
|
||||
println!("Message:\n{}", msg);
|
||||
} else {
|
||||
println!("Type: {}", "lightweight".yellow());
|
||||
}
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
if !self.yes {
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt("Create this tag?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", "Tag creation cancelled.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if self.dry_run {
|
||||
println!("\n{}", "Dry run - tag not created.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create tag
|
||||
let builder = TagBuilder::new()
|
||||
.name(&tag_name)
|
||||
.message_opt(message)
|
||||
.annotate(!self.lightweight)
|
||||
.sign(self.sign)
|
||||
.force(self.force);
|
||||
|
||||
builder.execute(&repo)?;
|
||||
|
||||
println!("{} Created tag {}", "✓".green(), tag_name.cyan());
|
||||
|
||||
// Push if requested
|
||||
if self.push {
|
||||
println!("{} Pushing tag to {}...", "→".blue(), &self.remote);
|
||||
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
|
||||
println!("{} Pushed tag to {}", "✓".green(), &self.remote);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str) -> Result<String> {
|
||||
loop {
|
||||
let latest = get_latest_version(repo, prefix)?;
|
||||
|
||||
println!("\n{}", "Version selection:".bold());
|
||||
|
||||
if let Some(ref version) = latest {
|
||||
println!("Latest version: {}{}", prefix, version);
|
||||
} else {
|
||||
println!("No existing version tags found");
|
||||
}
|
||||
|
||||
let options = vec![
|
||||
"Auto-detect bump from commits",
|
||||
"Bump major version",
|
||||
"Bump minor version",
|
||||
"Bump patch version",
|
||||
"Enter custom version",
|
||||
"Enter custom tag name",
|
||||
];
|
||||
|
||||
let selection = Select::new()
|
||||
.with_prompt("Select option")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
match selection {
|
||||
0 => {
|
||||
// Auto-detect
|
||||
let commits = repo.get_commits(50)?;
|
||||
let bump = suggest_version_bump(&commits);
|
||||
let version = latest.as_ref()
|
||||
.map(|v| bump_version(v, bump, None))
|
||||
.unwrap_or_else(|| Version::new(0, 1, 0));
|
||||
|
||||
println!("Suggested bump: {:?} → {}{}", bump, prefix, version);
|
||||
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt("Use this version?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if confirm {
|
||||
return Ok(format!("{}{}", prefix, version));
|
||||
}
|
||||
// User rejected, continue the loop
|
||||
}
|
||||
1 => {
|
||||
let version = latest.as_ref()
|
||||
.map(|v| bump_version(v, VersionBump::Major, None))
|
||||
.unwrap_or_else(|| Version::new(1, 0, 0));
|
||||
return Ok(format!("{}{}", prefix, version));
|
||||
}
|
||||
2 => {
|
||||
let version = latest.as_ref()
|
||||
.map(|v| bump_version(v, VersionBump::Minor, None))
|
||||
.unwrap_or_else(|| Version::new(0, 1, 0));
|
||||
return Ok(format!("{}{}", prefix, version));
|
||||
}
|
||||
3 => {
|
||||
let version = latest.as_ref()
|
||||
.map(|v| bump_version(v, VersionBump::Patch, None))
|
||||
.unwrap_or_else(|| Version::new(0, 0, 1));
|
||||
return Ok(format!("{}{}", prefix, version));
|
||||
}
|
||||
4 => {
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Enter version (e.g., 1.2.3)")
|
||||
.interact_text()?;
|
||||
let version = Version::parse(&input)?;
|
||||
return Ok(format!("{}{}", prefix, version));
|
||||
}
|
||||
5 => {
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Enter tag name")
|
||||
.interact_text()?;
|
||||
return Ok(input);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_tag_message(&self, repo: &GitRepo, version: &str) -> Result<String> {
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
// Get commits since last tag
|
||||
let tags = repo.get_tags()?;
|
||||
let commits = if let Some(latest_tag) = tags.first() {
|
||||
repo.get_commits_between(&latest_tag.name, "HEAD")?
|
||||
} else {
|
||||
repo.get_commits(50)?
|
||||
};
|
||||
|
||||
if commits.is_empty() {
|
||||
return Ok(format!("Release {}", version));
|
||||
}
|
||||
|
||||
println!("{} AI is generating tag message from {} commits...", "🤖", commits.len());
|
||||
|
||||
let generator = ContentGenerator::new(&config.llm).await?;
|
||||
generator.generate_tag_message(version, &commits).await
|
||||
}
|
||||
|
||||
fn input_message_interactive(&self, version: &str) -> Result<String> {
|
||||
let default_msg = format!("Release {}", version);
|
||||
|
||||
let use_editor = Confirm::new()
|
||||
.with_prompt("Open editor for tag message?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if use_editor {
|
||||
crate::utils::editor::edit_content(&default_msg)
|
||||
} else {
|
||||
Ok(Input::new()
|
||||
.with_prompt("Tag message")
|
||||
.default(default_msg)
|
||||
.interact_text()?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper trait
|
||||
trait TagBuilderExt {
|
||||
fn message_opt(self, message: Option<String>) -> Self;
|
||||
}
|
||||
|
||||
impl TagBuilderExt for TagBuilder {
|
||||
fn message_opt(self, message: Option<String>) -> Self {
|
||||
if let Some(m) = message {
|
||||
self.message(m)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user