chore: 升级版本至 0.1.10 并更新密钥环与加密相关描述

This commit is contained in:
2026-03-19 16:34:45 +08:00
parent e2d43315e3
commit a4c1041e41
5 changed files with 445 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "quicommit" name = "quicommit"
version = "0.1.9" version = "0.1.10"
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(alpha version)" description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"

View File

@@ -10,6 +10,8 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-blue.svg) ![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Crates.io Version](https://img.shields.io/crates/v/quicommit)
## Features ## Features
@@ -18,7 +20,7 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
- **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support - **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support
- **Smart Tagging**: Semantic version bumping with AI-generated release notes - **Smart Tagging**: Semantic version bumping with AI-generated release notes
- **Changelog Generation**: Automatic changelog generation in Keep a Changelog format - **Changelog Generation**: Automatic changelog generation in Keep a Changelog format
- **Security**: Encrypt sensitive data - **Security**: Use system keyring to store API keys securely
- **Interactive UI**: Beautiful CLI with previews and confirmations - **Interactive UI**: Beautiful CLI with previews and confirmations
## Installation ## Installation

View File

@@ -10,6 +10,7 @@
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-blue.svg) ![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Crates.io Version](https://img.shields.io/crates/v/quicommit)
## 主要功能 ## 主要功能
@@ -18,7 +19,7 @@
- **多配置管理**为不同场景管理多个Git身份支持SSH密钥和GPG签名配置 - **多配置管理**为不同场景管理多个Git身份支持SSH密钥和GPG签名配置
- **智能标签管理**基于语义版本自动检测升级AI生成标签信息 - **智能标签管理**基于语义版本自动检测升级AI生成标签信息
- **变更日志生成**自动生成Keep a Changelog格式的变更日志 - **变更日志生成**自动生成Keep a Changelog格式的变更日志
- **安全保护**加密存储敏感数据 - **安全保护**使用系统密钥环进行安全存储
- **交互式界面**美观的CLI界面支持预览和确认 - **交互式界面**美观的CLI界面支持预览和确认
## 安装 ## 安装

View File

@@ -1,12 +1,13 @@
use anyhow::{bail, 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, Password};
use std::path::PathBuf; use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager}; use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat; use crate::config::CommitFormat;
use crate::utils::keyring::{get_supported_providers, get_default_model, get_default_base_url, provider_needs_api_key}; use crate::utils::keyring::{get_supported_providers, get_default_model, get_default_base_url, provider_needs_api_key};
use crate::utils::crypto::{encrypt, decrypt};
/// Mask API key with asterisks for security /// Mask API key with asterisks for security
fn mask_api_key(key: Option<&str>) -> String { fn mask_api_key(key: Option<&str>) -> String {
@@ -130,6 +131,10 @@ enum ConfigSubcommand {
/// Output file (defaults to stdout) /// Output file (defaults to stdout)
#[arg(short, long)] #[arg(short, long)]
output: Option<String>, output: Option<String>,
/// Password for encryption (will prompt if not provided)
#[arg(short = 'p', long)]
password: Option<String>,
}, },
/// Import configuration /// Import configuration
@@ -137,6 +142,10 @@ enum ConfigSubcommand {
/// Input file /// Input file
#[arg(short, long)] #[arg(short, long)]
file: String, file: String,
/// Password for decryption (will prompt if file is encrypted)
#[arg(short = 'p', long)]
password: Option<String>,
}, },
/// List available LLM models /// List available LLM models
@@ -172,8 +181,8 @@ impl ConfigCommand {
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep, &config_path).await, Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep, &config_path).await,
Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await, Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await,
Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await, Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await,
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref(), &config_path).await, Some(ConfigSubcommand::Export { output, password }) => self.export_config(output.as_deref(), password.as_deref(), &config_path).await,
Some(ConfigSubcommand::Import { file }) => self.import_config(file, &config_path).await, Some(ConfigSubcommand::Import { file, password }) => self.import_config(file, password.as_deref(), &config_path).await,
Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await, Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await,
Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await, Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await,
Some(ConfigSubcommand::Path) => self.show_path(&config_path).await, Some(ConfigSubcommand::Path) => self.show_path(&config_path).await,
@@ -737,28 +746,90 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn export_config(&self, output: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> { async fn export_config(&self, output: Option<&str>, password: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let manager = self.get_manager(config_path)?; let manager = self.get_manager(config_path)?;
let toml = manager.export()?; let toml = manager.export()?;
let export_content = if let Some(path) = output {
let pwd = if let Some(p) = password {
p.to_string()
} else {
let confirm = Confirm::new()
.with_prompt("Encrypt the exported configuration?")
.default(true)
.interact()?;
if confirm {
let pwd1 = Password::new()
.with_prompt("Enter encryption password")
.interact()?;
let pwd2 = Password::new()
.with_prompt("Confirm encryption password")
.interact()?;
if pwd1 != pwd2 {
bail!("Passwords do not match");
}
pwd1
} else {
String::new()
}
};
if pwd.is_empty() {
toml
} else {
let encrypted = encrypt(toml.as_bytes(), &pwd)?;
format!("ENCRYPTED:{}", encrypted)
}
} else {
toml
};
match output { match output {
Some(path) => { Some(path) => {
std::fs::write(path, &toml)?; std::fs::write(path, &export_content)?;
if export_content.starts_with("ENCRYPTED:") {
println!("{} Configuration encrypted and exported to {}", "".green(), path);
} else {
println!("{} Configuration exported to {}", "".green(), path); println!("{} Configuration exported to {}", "".green(), path);
} }
}
None => { None => {
println!("{}", toml); println!("{}", export_content);
} }
} }
Ok(()) Ok(())
} }
async fn import_config(&self, file: &str, config_path: &Option<PathBuf>) -> Result<()> { async fn import_config(&self, file: &str, password: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
let content = std::fs::read_to_string(file)?; let content = std::fs::read_to_string(file)?;
let config_content = if content.starts_with("ENCRYPTED:") {
let encrypted_data = content.strip_prefix("ENCRYPTED:").unwrap();
let pwd = if let Some(p) = password {
p.to_string()
} else {
Password::new()
.with_prompt("Enter decryption password")
.interact()?
};
match decrypt(encrypted_data, &pwd) {
Ok(decrypted) => String::from_utf8(decrypted)
.map_err(|e| anyhow::anyhow!("Invalid UTF-8 in decrypted content: {}", e))?,
Err(e) => {
bail!("Failed to decrypt configuration: {}. Please check your password.", e);
}
}
} else {
content
};
let mut manager = self.get_manager(config_path)?; let mut manager = self.get_manager(config_path)?;
manager.import(&content)?; manager.import(&config_content)?;
manager.save()?; manager.save()?;
println!("{} Configuration imported from {}", "".green(), file); println!("{} Configuration imported from {}", "".green(), file);

View File

@@ -0,0 +1,359 @@
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn create_git_repo(dir: &PathBuf) -> std::process::Output {
std::process::Command::new("git")
.args(&["init"])
.current_dir(dir)
.output()
.expect("Failed to init git repo")
}
fn configure_git_user(dir: &PathBuf) {
std::process::Command::new("git")
.args(&["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.expect("Failed to configure git user name");
std::process::Command::new("git")
.args(&["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.expect("Failed to configure git user email");
}
fn setup_git_repo(dir: &PathBuf) {
create_git_repo(dir);
configure_git_user(dir);
}
fn init_quicommit(config_path: &PathBuf) {
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
}
mod config_export {
use super::*;
#[test]
fn test_export_to_stdout() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
init_quicommit(&config_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "export", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("version"))
.stdout(predicate::str::contains("[llm]"));
}
#[test]
fn test_export_to_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let export_path = temp_dir.path().join("exported.toml");
init_quicommit(&config_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "export",
"--config", config_path.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", ""
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Configuration exported"));
assert!(export_path.exists(), "Export file should be created");
let content = fs::read_to_string(&export_path).unwrap();
assert!(content.contains("version"), "Export should contain version");
assert!(content.contains("[llm]"), "Export should contain LLM config");
}
#[test]
fn test_export_encrypted() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let export_path = temp_dir.path().join("encrypted.toml");
init_quicommit(&config_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "export",
"--config", config_path.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", "test_password_123"
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("encrypted and exported"));
assert!(export_path.exists(), "Export file should be created");
let content = fs::read_to_string(&export_path).unwrap();
assert!(content.starts_with("ENCRYPTED:"), "Encrypted file should start with ENCRYPTED:");
assert!(!content.contains("[llm]"), "Encrypted content should not be readable");
}
}
mod config_import {
use super::*;
#[test]
fn test_import_plain_config() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let import_path = temp_dir.path().join("import.toml");
let plain_config = r#"
version = "1"
[llm]
provider = "openai"
model = "gpt-4"
max_tokens = 1000
temperature = 0.7
timeout = 60
api_key_storage = "keyring"
[commit]
format = "conventional"
auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
[tag]
version_prefix = "v"
auto_generate = true
gpg_sign = false
include_changelog = true
[changelog]
path = "CHANGELOG.md"
auto_generate = true
format = "keep-a-changelog"
include_hashes = false
include_authors = false
group_by_type = true
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
[language]
output_language = "en"
keep_types_english = true
keep_changelog_types_english = true
"#;
fs::write(&import_path, plain_config).unwrap();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "import",
"--config", config_path.to_str().unwrap(),
"--file", import_path.to_str().unwrap()
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Configuration imported"));
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.provider", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("openai"));
}
#[test]
fn test_import_encrypted_config() {
let temp_dir = TempDir::new().unwrap();
let config_path1 = temp_dir.path().join("config1.toml");
let config_path2 = temp_dir.path().join("config2.toml");
let export_path = temp_dir.path().join("encrypted.toml");
init_quicommit(&config_path1);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "set", "llm.provider", "anthropic",
"--config", config_path1.to_str().unwrap()
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "export",
"--config", config_path1.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", "secure_password"
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "import",
"--config", config_path2.to_str().unwrap(),
"--file", export_path.to_str().unwrap(),
"--password", "secure_password"
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Configuration imported"));
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.provider", "--config", config_path2.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("anthropic"));
}
#[test]
fn test_import_encrypted_wrong_password() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let export_path = temp_dir.path().join("encrypted.toml");
init_quicommit(&config_path);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "export",
"--config", config_path.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", "correct_password"
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "import",
"--config", config_path.to_str().unwrap(),
"--file", export_path.to_str().unwrap(),
"--password", "wrong_password"
]);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Failed to decrypt"));
}
}
mod config_export_import_roundtrip {
use super::*;
#[test]
fn test_roundtrip_plain() {
let temp_dir = TempDir::new().unwrap();
let config_path1 = temp_dir.path().join("config1.toml");
let config_path2 = temp_dir.path().join("config2.toml");
let export_path = temp_dir.path().join("export.toml");
init_quicommit(&config_path1);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "set", "llm.model", "gpt-4-turbo",
"--config", config_path1.to_str().unwrap()
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "export",
"--config", config_path1.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", ""
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "import",
"--config", config_path2.to_str().unwrap(),
"--file", export_path.to_str().unwrap()
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.model", "--config", config_path2.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("gpt-4-turbo"));
}
#[test]
fn test_roundtrip_encrypted() {
let temp_dir = TempDir::new().unwrap();
let config_path1 = temp_dir.path().join("config1.toml");
let config_path2 = temp_dir.path().join("config2.toml");
let export_path = temp_dir.path().join("encrypted.toml");
let password = "my_secure_password_123";
init_quicommit(&config_path1);
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "set", "llm.provider", "deepseek",
"--config", config_path1.to_str().unwrap()
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "set", "llm.model", "deepseek-chat",
"--config", config_path1.to_str().unwrap()
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "export",
"--config", config_path1.to_str().unwrap(),
"--output", export_path.to_str().unwrap(),
"--password", password
]);
cmd.assert().success();
let exported_content = fs::read_to_string(&export_path).unwrap();
assert!(exported_content.starts_with("ENCRYPTED:"));
assert!(!exported_content.contains("deepseek"));
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&[
"config", "import",
"--config", config_path2.to_str().unwrap(),
"--file", export_path.to_str().unwrap(),
"--password", password
]);
cmd.assert().success();
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.provider", "--config", config_path2.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("deepseek"));
let mut cmd = Command::cargo_bin("quicommit").unwrap();
cmd.args(&["config", "get", "llm.model", "--config", config_path2.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("deepseek-chat"));
}
}