1 Commits

10 changed files with 61 additions and 173 deletions

View File

@@ -1,11 +1,11 @@
[package] [package]
name = "quicommit" name = "quicommit"
version = "0.1.0" version = "0.1.2"
edition = "2024" edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"] authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation" description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"
license = "MIT" license = "MIT"
repository = "https://github.com/yourusername/quicommit" repository = "https://git.lyz.one/SidneyZhang/QuiCommit"
keywords = ["git", "commit", "ai", "cli", "automation"] keywords = ["git", "commit", "ai", "cli", "automation"]
categories = ["command-line-utilities", "development-tools"] categories = ["command-line-utilities", "development-tools"]

View File

@@ -148,17 +148,22 @@ impl CommitCommand {
} }
} }
if self.dry_run {
println!("\n{}", "Dry run - commit not created.".yellow());
return Ok(());
}
// Execute commit
let result = if self.amend { let result = if self.amend {
if self.dry_run {
println!("\n{}", "Dry run - commit not amended.".yellow());
return Ok(());
}
self.amend_commit(&repo, &commit_message)?; self.amend_commit(&repo, &commit_message)?;
None None
} else { } else {
Some(repo.commit(&commit_message, self.sign)?) if self.dry_run {
println!("\n{}", "Dry run - commit not created.".yellow());
return Ok(());
}
CommitBuilder::new()
.message(&commit_message)
.sign(self.sign)
.execute(&repo)?
}; };
if let Some(commit_oid) = result { if let Some(commit_oid) = result {

View File

@@ -185,7 +185,14 @@ impl InitCommand {
// LLM provider selection // LLM provider selection
println!("\n{}", "Select your preferred LLM provider:".bold()); println!("\n{}", "Select your preferred LLM provider:".bold());
let providers = vec!["Ollama (local)", "OpenAI", "Anthropic Claude"]; let providers = vec![
"Ollama (local)",
"OpenAI",
"Anthropic Claude",
"Kimi (Moonshot AI)",
"DeepSeek",
"OpenRouter"
];
let provider_idx = Select::new() let provider_idx = Select::new()
.items(&providers) .items(&providers)
.default(0) .default(0)
@@ -195,6 +202,9 @@ impl InitCommand {
0 => "ollama", 0 => "ollama",
1 => "openai", 1 => "openai",
2 => "anthropic", 2 => "anthropic",
3 => "kimi",
4 => "deepseek",
5 => "openrouter",
_ => "ollama", _ => "ollama",
}; };
@@ -211,6 +221,21 @@ impl InitCommand {
.with_prompt("Anthropic API key") .with_prompt("Anthropic API key")
.interact_text()?; .interact_text()?;
manager.set_anthropic_api_key(api_key); manager.set_anthropic_api_key(api_key);
} else if provider == "kimi" {
let api_key: String = Input::new()
.with_prompt("Kimi API key")
.interact_text()?;
manager.set_kimi_api_key(api_key);
} else if provider == "deepseek" {
let api_key: String = Input::new()
.with_prompt("DeepSeek API key")
.interact_text()?;
manager.set_deepseek_api_key(api_key);
} else if provider == "openrouter" {
let api_key: String = Input::new()
.with_prompt("OpenRouter API key")
.interact_text()?;
manager.set_openrouter_api_key(api_key);
} }
Ok(()) Ok(())

View File

@@ -1,12 +1,12 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
use crate::config::{GitProfile, TokenConfig, TokenType, ProfileComparison}; use crate::config::{GitProfile, TokenConfig, TokenType};
use crate::config::profile::{GpgConfig, SshConfig}; use crate::config::profile::{GpgConfig, SshConfig};
use crate::git::{find_repo, GitConfigHelper, UserConfig}; use crate::git::find_repo;
use crate::utils::validators::validate_profile_name; use crate::utils::validators::validate_profile_name;
/// Manage Git profiles /// Manage Git profiles

View File

@@ -144,7 +144,6 @@ impl TagCommand {
return Ok(()); return Ok(());
} }
// Create tag
let builder = TagBuilder::new() let builder = TagBuilder::new()
.name(&tag_name) .name(&tag_name)
.message_opt(message) .message_opt(message)

View File

@@ -7,10 +7,9 @@ use std::path::{Path, PathBuf};
pub mod manager; pub mod manager;
pub mod profile; pub mod profile;
pub use manager::ConfigManager;
pub use profile::{ pub use profile::{
GitProfile, ProfileSettings, SshConfig, GpgConfig, TokenConfig, TokenType, GitProfile, TokenConfig, TokenType,
UsageStats, ProfileComparison, ConfigDifference UsageStats, ProfileComparison
}; };
/// Application configuration /// Application configuration

View File

@@ -190,115 +190,8 @@ impl ContentGenerator {
} }
} }
/// Batch generator for multiple operations
pub struct BatchGenerator {
generator: ContentGenerator,
}
impl BatchGenerator {
/// Create new batch generator
pub async fn new(config: &LlmConfig) -> Result<Self> {
let generator = ContentGenerator::new(config).await?;
Ok(Self { generator })
}
/// Generate commits for multiple repositories
pub async fn generate_commits_batch<'a>(
&self,
repos: &[&'a GitRepo],
format: CommitFormat,
) -> Vec<(&'a str, Result<GeneratedCommit>)> {
let mut results = vec![];
for repo in repos {
let result = self.generator.generate_commit_from_repo(repo, format).await;
results.push((repo.path().to_str().unwrap_or("unknown"), result));
}
results
}
/// Generate changelog for multiple versions
pub async fn generate_changelog_batch(
&self,
repo: &GitRepo,
versions: &[String],
) -> Vec<(String, Result<String>)> {
let mut results = vec![];
// Get all tags
let tags = repo.get_tags().unwrap_or_default();
for (i, version) in versions.iter().enumerate() {
let from_tag = if i + 1 < tags.len() {
tags.get(i + 1).map(|t| t.name.as_str())
} else {
None
};
let result = self.generator.generate_changelog_from_repo(repo, version, from_tag).await;
results.push((version.clone(), result));
}
results
}
}
/// Generator options
#[derive(Debug, Clone)]
pub struct GeneratorOptions {
pub auto_commit: bool,
pub auto_push: bool,
pub interactive: bool,
pub dry_run: bool,
}
impl Default for GeneratorOptions {
fn default() -> Self {
Self {
auto_commit: false,
auto_push: false,
interactive: true,
dry_run: false,
}
}
}
/// Generate with options
pub async fn generate_with_options(
repo: &GitRepo,
config: &LlmConfig,
format: CommitFormat,
options: GeneratorOptions,
) -> Result<Option<GeneratedCommit>> {
let generator = ContentGenerator::new(config).await?;
let generated = if options.interactive {
generator.generate_commit_interactive(repo, format).await?
} else {
generator.generate_commit_from_repo(repo, format).await?
};
if options.dry_run {
println!("{}", generated.to_conventional());
return Ok(Some(generated));
}
if options.auto_commit {
let message = generated.to_conventional();
repo.commit(&message, false)?;
if options.auto_push {
repo.push("origin", "HEAD")?;
}
}
Ok(Some(generated))
}
/// Fallback generators when LLM is not available /// Fallback generators when LLM is not available
pub mod fallback { pub mod fallback {
use super::*;
use crate::git::commit::create_date_commit_message; use crate::git::commit::create_date_commit_message;
/// Generate simple commit message without LLM /// Generate simple commit message without LLM

View File

@@ -9,11 +9,11 @@ pub struct CommitBuilder {
description: Option<String>, description: Option<String>,
body: Option<String>, body: Option<String>,
footer: Option<String>, footer: Option<String>,
message: Option<String>,
breaking: bool, breaking: bool,
sign: bool, sign: bool,
amend: bool, amend: bool,
no_verify: bool, no_verify: bool,
dry_run: bool,
format: crate::config::CommitFormat, format: crate::config::CommitFormat,
} }
@@ -26,11 +26,11 @@ impl CommitBuilder {
description: None, description: None,
body: None, body: None,
footer: None, footer: None,
message: None,
breaking: false, breaking: false,
sign: false, sign: false,
amend: false, amend: false,
no_verify: false, no_verify: false,
dry_run: false,
format: crate::config::CommitFormat::Conventional, format: crate::config::CommitFormat::Conventional,
} }
} }
@@ -65,6 +65,12 @@ impl CommitBuilder {
self self
} }
/// Set message
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
/// Mark as breaking change /// Mark as breaking change
pub fn breaking(mut self, breaking: bool) -> Self { pub fn breaking(mut self, breaking: bool) -> Self {
self.breaking = breaking; self.breaking = breaking;
@@ -89,12 +95,6 @@ impl CommitBuilder {
self self
} }
/// Dry run (don't actually commit)
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
/// Set commit format /// Set commit format
pub fn format(mut self, format: crate::config::CommitFormat) -> Self { pub fn format(mut self, format: crate::config::CommitFormat) -> Self {
self.format = format; self.format = format;
@@ -103,6 +103,10 @@ impl CommitBuilder {
/// Build commit message /// Build commit message
pub fn build_message(&self) -> Result<String> { 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() let commit_type = self.commit_type.as_ref()
.ok_or_else(|| anyhow::anyhow!("Commit type is required"))?; .ok_or_else(|| anyhow::anyhow!("Commit type is required"))?;
@@ -139,33 +143,13 @@ impl CommitBuilder {
pub fn execute(&self, repo: &GitRepo) -> Result<Option<String>> { pub fn execute(&self, repo: &GitRepo) -> Result<Option<String>> {
let message = self.build_message()?; let message = self.build_message()?;
if self.dry_run {
return Ok(Some(message));
}
// Check if there are staged changes
let staged_files = repo.get_staged_files()?;
if staged_files.is_empty() && !self.amend {
bail!("No staged changes to commit. Use 'git add' to stage files first.");
}
// Validate message
match self.format {
crate::config::CommitFormat::Conventional => {
crate::utils::validators::validate_conventional_commit(&message)?;
}
crate::config::CommitFormat::Commitlint => {
crate::utils::validators::validate_commitlint_commit(&message)?;
}
}
if self.amend { if self.amend {
self.amend_commit(repo, &message)?; self.amend_commit(repo, &message)?;
Ok(None)
} else { } else {
repo.commit(&message, self.sign)?; repo.commit(&message, self.sign)?;
Ok(None)
} }
Ok(None)
} }
fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> { fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> {

View File

@@ -128,7 +128,7 @@ impl GitRepo {
} }
/// Create a signature using repository configuration /// Create a signature using repository configuration
pub fn create_signature(&self) -> Result<Signature> { pub fn create_signature(&self) -> Result<Signature<'_>> {
let name = self.get_user_name()?; let name = self.get_user_name()?;
let email = self.get_user_email()?; let email = self.get_user_email()?;
let time = git2::Time::new(std::time::SystemTime::now() let time = git2::Time::new(std::time::SystemTime::now()

View File

@@ -9,7 +9,6 @@ pub struct TagBuilder {
annotate: bool, annotate: bool,
sign: bool, sign: bool,
force: bool, force: bool,
dry_run: bool,
version_prefix: String, version_prefix: String,
} }
@@ -22,7 +21,6 @@ impl TagBuilder {
annotate: true, annotate: true,
sign: false, sign: false,
force: false, force: false,
dry_run: false,
version_prefix: "v".to_string(), version_prefix: "v".to_string(),
} }
} }
@@ -57,12 +55,6 @@ impl TagBuilder {
self self
} }
/// Dry run (don't actually create tag)
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
/// Set version prefix /// Set version prefix
pub fn version_prefix(mut self, prefix: impl Into<String>) -> Self { pub fn version_prefix(mut self, prefix: impl Into<String>) -> Self {
self.version_prefix = prefix.into(); self.version_prefix = prefix.into();
@@ -92,15 +84,6 @@ impl TagBuilder {
let name = self.name.as_ref() let name = self.name.as_ref()
.ok_or_else(|| anyhow::anyhow!("Tag name is required"))?; .ok_or_else(|| anyhow::anyhow!("Tag name is required"))?;
if self.dry_run {
println!("Would create tag: {}", name);
if self.annotate {
println!("Message: {}", self.build_message()?);
}
return Ok(());
}
// Check if tag already exists
if !self.force { if !self.force {
let existing_tags = repo.get_tags()?; let existing_tags = repo.get_tags()?;
if existing_tags.iter().any(|t| t.name == *name) { if existing_tags.iter().any(|t| t.name == *name) {