12 Commits

25 changed files with 3299 additions and 474 deletions

View File

@@ -5,35 +5,42 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [0.1.4] - 2026-02-01
### ✨ 新功能
- 新增 `test3.txt`,支持中文输出测试
- `generator` 模块新增 `language` 参数,可指定提交信息语言
- `commit``tag` 命令新增自动 push 功能
- 提交、标签及变更日志命令现支持多语言输出
- 新增 Kimi、DeepSeek、OpenRouter 三家 LLM 提供商支持
- 首次创建仓库,完成 0.1.0 版本基础功能
### 🐞 错误修复
- 修复 `git/commit.rs` 中的提交错误信息问题
- 修复 Git2 错误处理逻辑(仓库打开功能暂不可用)
- 统一代码风格(`rustfmt` 修正)
### 📚 文档
- 更新 README补充新的安装方式与 CLI 选项说明
- 优化 README 内容,新增 LLM 提供商介绍
### 🔧 其他变更
- 新增个人访问令牌、使用统计与配置校验功能
- 添加 `test2.txt` 占位文件
## [0.1.0] - 2026-01-30
### Added ### Added
- Initial project structure
- Initial release of QuiCommit - Core functionality for git operations
- AI-powered commit message generation using LLM APIs (OpenAI, Anthropic) or local Ollama - LLM integration
- Support for Conventional Commits and @commitlint formats - Configuration management
- Multiple Git profile management with SSH and GPG support - CLI interface
- Smart tag generation with semantic version bumping
- Automatic changelog generation
- Interactive CLI with beautiful prompts and previews
- Encrypted storage for sensitive data
- Cross-platform support (Linux, macOS, Windows)
### Features ### Features
- **Commit Generation**: Automatically generate conventional commit messages from git diffs - **Commit Generation**: Automatically generate conventional commit messages from git diffs
- **Profile Management**: Switch between multiple Git identities for different contexts - **Profile Management**: Switch between multiple Git identities for different contexts
- **Tag Management**: Create annotated tags with AI-generated release notes - **Tag Management**: Create annotated tags with AI-generated release notes
- **Changelog**: Generate and maintain changelog in Keep a Changelog format - **Changelog**: Generate and maintain changelog in Keep a Changelog format
- **Security**: Encrypt SSH passphrases and API keys - **Security**: Encrypt SSH passphrases and API keys
- **Interactive UI**: Beautiful CLI with prompts and previews - **Interactive UI**: Beautiful CLI with prompts and previews
## [0.1.0] - 2026-01-30
### Added
- Initial project structure
- Core functionality for git operations
- LLM integration
- Configuration management
- CLI interface

View File

@@ -1,11 +1,11 @@
[package] [package]
name = "quicommit" name = "quicommit"
version = "0.1.0" version = "0.1.4"
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"]

234
README.md
View File

@@ -4,6 +4,8 @@ English | [中文文档](README_zh.md)
A powerful AI-powered Git assistant for generating conventional commits, tags, and changelogs. Manage multiple Git profiles for different work contexts. A powerful AI-powered Git assistant for generating conventional commits, tags, and changelogs. Manage multiple Git profiles for different work contexts.
[Still in early development, some features may not be complete. Feedback and contributions are welcome.]
![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)
@@ -19,9 +21,19 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
## Installation ## Installation
### Cargo Install
The cargo-installed version may temporarily lag behind the source code progress.
```bash ```bash
git clone https://github.com/yourusername/quicommit.git cargo install quicommit
cd quicommit ```
### Install from Source
```bash
git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
cd QuiCommit
cargo build --release cargo build --release
cargo install --path . cargo install --path .
``` ```
@@ -50,6 +62,12 @@ quicommit commit -a
# Skip confirmation # Skip confirmation
quicommit commit --yes quicommit commit --yes
# Use date-based commit message
quicommit commit --date
# Push after committing
quicommit commit --push
``` ```
### Create Tag ### Create Tag
@@ -63,6 +81,12 @@ quicommit tag --bump minor
# Custom tag name # Custom tag name
quicommit tag -n v1.0.0 quicommit tag -n v1.0.0
# AI-generate tag message
quicommit tag --generate
# Create tag and push to remote
quicommit tag --push
``` ```
### Generate Changelog ### Generate Changelog
@@ -73,6 +97,15 @@ quicommit changelog
# Generate for specific version # Generate for specific version
quicommit changelog -v 1.0.0 quicommit changelog -v 1.0.0
# AI-generate changelog
quicommit changelog --generate
# Initialize new changelog file
quicommit changelog --init
# Specify output file
quicommit changelog -o RELEASE_NOTES.md
``` ```
### Manage Profiles ### Manage Profiles
@@ -84,11 +117,41 @@ quicommit profile add
# List profiles # List profiles
quicommit profile list quicommit profile list
# Show profile details
quicommit profile show
# Switch profile # Switch profile
quicommit profile switch quicommit profile switch
# Set default profile
quicommit profile set-default personal
# Set profile for current repo # Set profile for current repo
quicommit profile set-repo personal quicommit profile set-repo personal
# Apply profile to current repo
quicommit profile apply
# Apply profile globally
quicommit profile apply --global
# Copy profile
quicommit profile copy personal work
# Edit profile
quicommit profile edit personal
# Remove profile
quicommit profile remove old-profile
# Check profile
quicommit profile check
# View usage statistics
quicommit profile stats
# Manage profile tokens
quicommit profile token
``` ```
### Configure LLM ### Configure LLM
@@ -109,17 +172,41 @@ quicommit config set-anthropic-key YOUR_API_KEY
# Configure Kimi (Moonshot AI) # Configure Kimi (Moonshot AI)
quicommit config set-llm kimi quicommit config set-llm kimi
quicommit config set-kimi-key YOUR_API_KEY quicommit config set-kimi-key YOUR_API_KEY
quicommit config set-kimi --base-url https://api.moonshot.cn/v1 --model moonshot-v1-8k
# Configure DeepSeek # Configure DeepSeek
quicommit config set-llm deepseek quicommit config set-llm deepseek
quicommit config set-deepseek-key YOUR_API_KEY quicommit config set-deepseek-key YOUR_API_KEY
quicommit config set-deepseek --base-url https://api.deepseek.com/v1 --model deepseek-chat
# Configure OpenRouter # Configure OpenRouter
quicommit config set-llm openrouter quicommit config set-llm openrouter
quicommit config set-openrouter-key YOUR_API_KEY quicommit config set-openrouter-key YOUR_API_KEY
quicommit config set-openrouter --base-url https://openrouter.ai/api/v1 --model openai/gpt-4
# Set commit format
quicommit config set-commit-format conventional
# Set version prefix
quicommit config set-version-prefix v
# Set changelog path
quicommit config set-changelog-path CHANGELOG.md
# Set output language
quicommit config set-language en
# Set keep commit types in English
quicommit config set-keep-types-english true
# Set keep changelog types in English
quicommit config set-keep-changelog-types-english true
# Test LLM connection # Test LLM connection
quicommit config test-llm quicommit config test-llm
# Reset configuration to defaults
quicommit config reset
``` ```
## Command Reference ## Command Reference
@@ -137,17 +224,23 @@ quicommit config test-llm
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| `-t, --commit-type` | Commit type (feat, fix, etc.) | | `--commit-type` | Commit type (feat, fix, etc.) |
| `-s, --scope` | Commit scope | | `-s, --scope` | Commit scope |
| `-m, --message` | Commit description | | `-m, --message` | Commit description |
| `--body` | Commit body | | `--body` | Commit body |
| `--breaking` | Mark as breaking change | | `-b, --breaking` | Mark as breaking change |
| `-d, --date` | Use date-based commit message |
| `--manual` | Manual input, skip AI | | `--manual` | Manual input, skip AI |
| `-a, --all` | Stage all changes | | `-a, --all` | Stage all changes |
| `-S, --sign` | GPG sign commit | | `-S, --sign` | GPG sign commit |
| `--amend` | Amend previous commit | | `--amend` | Amend previous commit |
| `--dry-run` | Show without committing | | `--dry-run` | Show without committing |
| `--conventional` | Use Conventional Commits format |
| `--commitlint` | Use commitlint format |
| `--no-verify` | Skip commit message verification |
| `-y, --yes` | Skip confirmation | | `-y, --yes` | Skip confirmation |
| `--push` | Push after committing |
| `--remote` | Specify remote repository (default: origin) |
### Tag Options ### Tag Options
@@ -158,14 +251,35 @@ quicommit config test-llm
| `-m, --message` | Tag message | | `-m, --message` | Tag message |
| `-g, --generate` | AI-generate message | | `-g, --generate` | AI-generate message |
| `-S, --sign` | GPG sign tag | | `-S, --sign` | GPG sign tag |
| `--lightweight` | Create lightweight tag | | `-l, --lightweight` | Create lightweight tag |
| `--push` | Push to remote | | `-f, --force` | Force overwrite existing tag |
| `-p, --push` | Push to remote |
| `-r, --remote` | Specify remote repository (default: origin) |
| `--dry-run` | Dry run |
| `-y, --yes` | Skip confirmation |
### Changelog Options
| Option | Description |
|--------|-------------|
| `-o, --output` | Output file path |
| `-v, --version` | Generate for specific version |
| `-f, --from` | Generate from specific tag |
| `-t, --to` | Generate to specific ref (default: HEAD) |
| `-i, --init` | Initialize new changelog file |
| `-g, --generate` | AI-generate changelog |
| `--prepend` | Prepend to existing changelog |
| `--include-hashes` | Include commit hashes |
| `--include-authors` | Include authors |
| `--format` | Format (keep-a-changelog, github-releases) |
| `--dry-run` | Dry run (output to stdout) |
| `-y, --yes` | Skip confirmation | | `-y, --yes` | Skip confirmation |
## Configuration File ## Configuration File
Location: Location:
- Linux/macOS: `~/.config/quicommit/config.toml` - Linux: `~/.config/quicommit/config.toml`
- macOS: `~/Library/Application Support/quicommit/config.toml`
- Windows: `%APPDATA%\quicommit\config.toml` - Windows: `%APPDATA%\quicommit\config.toml`
```toml ```toml
@@ -174,15 +288,27 @@ default_profile = "personal"
[profiles.personal] [profiles.personal]
name = "personal" name = "personal"
user_name = "John Doe" user_name = "Your Name"
user_email = "john@example.com" user_email = "your.email@example.com"
description = "Personal projects"
is_work = false
[profiles.work] [profiles.work]
name = "work" name = "work"
user_name = "John Doe" user_name = "Your Name"
user_email = "john@company.com" user_email = "your.name@company.com"
description = "Work projects"
is_work = true is_work = true
organization = "Acme Corp" organization = "Your Company"
[profiles.work.ssh]
private_key_path = "/home/user/.ssh/id_rsa_work"
agent_forwarding = true
[profiles.work.gpg]
key_id = "YOUR_GPG_KEY_ID"
program = "gpg"
use_agent = true
[llm] [llm]
provider = "ollama" provider = "ollama"
@@ -198,19 +324,50 @@ model = "llama2"
model = "gpt-4" model = "gpt-4"
base_url = "https://api.openai.com/v1" base_url = "https://api.openai.com/v1"
[llm.anthropic]
model = "claude-3-sonnet-20240229"
[llm.kimi]
model = "moonshot-v1-8k"
[llm.deepseek]
model = "deepseek-chat"
[llm.openrouter]
model = "openai/gpt-4"
[commit] [commit]
format = "conventional" format = "conventional"
auto_generate = true auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100 max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
[tag] [tag]
version_prefix = "v" version_prefix = "v"
auto_generate = true auto_generate = true
gpg_sign = false
include_changelog = true
[changelog] [changelog]
path = "CHANGELOG.md" path = "CHANGELOG.md"
auto_generate = true auto_generate = true
format = "keep-a-changelog"
include_hashes = false
include_authors = false
group_by_type = true group_by_type = true
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
[repo_profiles]
"/path/to/work/project" = "work"
"/path/to/personal/project" = "personal"
``` ```
## Environment Variables ## Environment Variables
@@ -227,14 +384,32 @@ group_by_type = true
# View current configuration # View current configuration
quicommit config list quicommit config list
# Show configuration details
quicommit config show
# Edit configuration file
quicommit config edit
# Set configuration value
quicommit config set llm.provider ollama
# Get configuration value
quicommit config get llm.provider
# Test LLM connection # Test LLM connection
quicommit config test-llm quicommit config test-llm
# List available models # List available models
quicommit config list-models quicommit config list-models
# Edit configuration # Export configuration
quicommit config edit quicommit config export -o config-backup.toml
# Import configuration
quicommit config import -i config-backup.toml
# Reset configuration
quicommit config reset --force
``` ```
## Contributing ## Contributing
@@ -253,8 +428,8 @@ Contributions are welcome! Please follow these steps:
```bash ```bash
# Clone repository # Clone repository
git clone https://github.com/YOUR_USERNAME/quicommit.git git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
cd quicommit cd QuiCommit
# Fetch dependencies # Fetch dependencies
cargo fetch cargo fetch
@@ -282,11 +457,36 @@ cargo fmt --check
``` ```
src/ src/
├── commands/ # CLI command implementations ├── commands/ # CLI command implementations
│ ├── commit.rs
│ ├── tag.rs
│ ├── changelog.rs
│ ├── profile.rs
│ ├── config.rs
│ └── init.rs
├── config/ # Configuration management ├── config/ # Configuration management
│ ├── manager.rs
│ └── profile.rs
├── generator/ # AI content generation ├── generator/ # AI content generation
├── git/ # Git operations ├── git/ # Git operations
│ ├── commit.rs
│ ├── tag.rs
│ └── changelog.rs
├── llm/ # LLM provider implementations ├── llm/ # LLM provider implementations
└── utils/ # Utility functions │ ├── ollama.rs
│ ├── openai.rs
│ ├── anthropic.rs
│ ├── kimi.rs
│ ├── deepseek.rs
│ └── openrouter.rs
├── i18n/ # Internationalization support
│ ├── messages.rs
│ └── translator.rs
├── utils/ # Utility functions
│ ├── validators.rs
│ ├── formatter.rs
│ ├── crypto.rs
│ └── editor.rs
└── main.rs # Program entry point
``` ```
## License ## License

View File

@@ -4,8 +4,10 @@
一款强大的AI驱动的Git助手用于生成规范化的提交信息、标签和变更日志并支持管理多个Git配置。 一款强大的AI驱动的Git助手用于生成规范化的提交信息、标签和变更日志并支持管理多个Git配置。
![Rust](https://img.shields.io/badge/rust-%23000000.svg?logo=rust&logoColor=white) 【目前还处在早期开发阶段,依然有一些功能未完善,欢迎反馈和贡献。】
![LICENSE](https://img.shields.io/badge/license-MIT-blue.svg)
![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)
## 主要功能 ## 主要功能
@@ -19,11 +21,19 @@
## 安装 ## 安装
目前,整体工具还在开发,并不保证各项功能准确达到既定目标。但依然十分欢迎参与贡献、反馈问题和建议。 ### cargo安装
cargo安装版本可能暂时不如源码进展快速。
```bash ```bash
git clone https://github.com/yourusername/quicommit.git cargo install quicommit
cd quicommit ```
### 从源代码安装
```bash
git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
cd QuiCommit
cargo build --release cargo build --release
cargo install --path . cargo install --path .
``` ```
@@ -52,6 +62,12 @@ quicommit commit -a
# 跳过确认直接提交 # 跳过确认直接提交
quicommit commit --yes quicommit commit --yes
# 使用日期格式的提交信息
quicommit commit --date
# 提交后推送到远程
quicommit commit --push
``` ```
### 创建标签 ### 创建标签
@@ -65,6 +81,12 @@ quicommit tag --bump minor
# 自定义标签名 # 自定义标签名
quicommit tag -n v1.0.0 quicommit tag -n v1.0.0
# AI生成标签信息
quicommit tag --generate
# 创建标签并推送到远程
quicommit tag --push
``` ```
### 生成变更日志 ### 生成变更日志
@@ -75,6 +97,15 @@ quicommit changelog
# 为特定版本生成 # 为特定版本生成
quicommit changelog -v 1.0.0 quicommit changelog -v 1.0.0
# AI生成变更日志
quicommit changelog --generate
# 初始化新的变更日志文件
quicommit changelog --init
# 指定输出文件
quicommit changelog -o RELEASE_NOTES.md
``` ```
### 配置管理 ### 配置管理
@@ -86,11 +117,41 @@ quicommit profile add
# 查看配置列表 # 查看配置列表
quicommit profile list quicommit profile list
# 显示配置详情
quicommit profile show
# 切换配置 # 切换配置
quicommit profile switch quicommit profile switch
# 设置默认配置
quicommit profile set-default personal
# 设置当前仓库的配置 # 设置当前仓库的配置
quicommit profile set-repo personal quicommit profile set-repo personal
# 应用配置到当前仓库
quicommit profile apply
# 全局应用配置
quicommit profile apply --global
# 复制配置
quicommit profile copy personal work
# 编辑配置
quicommit profile edit personal
# 删除配置
quicommit profile remove old-profile
# 检查配置
quicommit profile check
# 查看使用统计
quicommit profile stats
# 管理配置的令牌
quicommit profile token
``` ```
### LLM配置 ### LLM配置
@@ -111,17 +172,41 @@ quicommit config set-anthropic-key YOUR_API_KEY
# 配置Kimi # 配置Kimi
quicommit config set-llm kimi quicommit config set-llm kimi
quicommit config set-kimi-key YOUR_API_KEY quicommit config set-kimi-key YOUR_API_KEY
quicommit config set-kimi --base-url https://api.moonshot.cn/v1 --model moonshot-v1-8k
# 配置DeepSeek # 配置DeepSeek
quicommit config set-llm deepseek quicommit config set-llm deepseek
quicommit config set-deepseek-key YOUR_API_KEY quicommit config set-deepseek-key YOUR_API_KEY
quicommit config set-deepseek --base-url https://api.deepseek.com/v1 --model deepseek-chat
# 配置OpenRouter # 配置OpenRouter
quicommit config set-llm openrouter quicommit config set-llm openrouter
quicommit config set-openrouter-key YOUR_API_KEY quicommit config set-openrouter-key YOUR_API_KEY
quicommit config set-openrouter --base-url https://openrouter.ai/api/v1 --model openai/gpt-4
# 设置提交格式
quicommit config set-commit-format conventional
# 设置版本前缀
quicommit config set-version-prefix v
# 设置变更日志路径
quicommit config set-changelog-path CHANGELOG.md
# 设置输出语言
quicommit config set-language zh
# 设置保持提交类型为英文
quicommit config set-keep-types-english true
# 设置保持变更日志类型为英文
quicommit config set-keep-changelog-types-english true
# 测试LLM连接 # 测试LLM连接
quicommit config test-llm quicommit config test-llm
# 重置配置为默认值
quicommit config reset
``` ```
## 命令参考 ## 命令参考
@@ -139,17 +224,23 @@ quicommit config test-llm
| 选项 | 说明 | | 选项 | 说明 |
|------|------| |------|------|
| `-t, --commit-type` | 提交类型feat、fix等 | | `--commit-type` | 提交类型feat、fix等 |
| `-s, --scope` | 提交范围 | | `-s, --scope` | 提交范围 |
| `-m, --message` | 提交描述 | | `-m, --message` | 提交描述 |
| `--body` | 提交正文 | | `--body` | 提交正文 |
| `--breaking` | 标记为破坏性变更 | | `-b, --breaking` | 标记为破坏性变更 |
| `-d, --date` | 使用日期格式的提交信息 |
| `--manual` | 手动输入跳过AI生成 | | `--manual` | 手动输入跳过AI生成 |
| `-a, --all` | 暂存所有更改 | | `-a, --all` | 暂存所有更改 |
| `-S, --sign` | GPG签名提交 | | `-S, --sign` | GPG签名提交 |
| `--amend` | 修改上一次提交 | | `--amend` | 修改上一次提交 |
| `--dry-run` | 试运行,不实际提交 | | `--dry-run` | 试运行,不实际提交 |
| `--conventional` | 使用Conventional Commits格式 |
| `--commitlint` | 使用commitlint格式 |
| `--no-verify` | 不验证提交信息 |
| `-y, --yes` | 跳过确认提示 | | `-y, --yes` | 跳过确认提示 |
| `--push` | 提交后推送到远程 |
| `--remote` | 指定远程仓库默认origin |
### tag命令选项 ### tag命令选项
@@ -160,15 +251,36 @@ quicommit config test-llm
| `-m, --message` | 标签信息 | | `-m, --message` | 标签信息 |
| `-g, --generate` | AI生成标签信息 | | `-g, --generate` | AI生成标签信息 |
| `-S, --sign` | GPG签名标签 | | `-S, --sign` | GPG签名标签 |
| `--lightweight` | 创建轻量标签 | | `-l, --lightweight` | 创建轻量标签 |
| `--push` | 推送到远程 | | `-f, --force` | 强制覆盖已存在的标签 |
| `-p, --push` | 推送到远程 |
| `-r, --remote` | 指定远程仓库默认origin |
| `--dry-run` | 试运行 |
| `-y, --yes` | 跳过确认提示 |
### changelog命令选项
| 选项 | 说明 |
|------|------|
| `-o, --output` | 输出文件路径 |
| `-v, --version` | 为特定版本生成 |
| `-f, --from` | 从指定标签生成 |
| `-t, --to` | 生成到指定引用默认HEAD |
| `-i, --init` | 初始化新的变更日志文件 |
| `-g, --generate` | AI生成变更日志 |
| `--prepend` | 添加到现有变更日志开头 |
| `--include-hashes` | 包含提交哈希 |
| `--include-authors` | 包含作者信息 |
| `--format` | 格式keep-a-changelog、github-releases |
| `--dry-run` | 试运行输出到stdout |
| `-y, --yes` | 跳过确认提示 | | `-y, --yes` | 跳过确认提示 |
## 配置文件 ## 配置文件
配置文件位置: 配置文件位置:
- Linux/macOS: `~/.config/quicommit/config.toml` - Linux: `~/.config/quicommit/config.toml`
- Windows: `%APPDATA%\quicommit\config.toml` - macOS: `~/Library/Application Support/quicommit/config.toml`
- Windows: `%APPDATA%\quicommit/config.toml`
```toml ```toml
version = "1" version = "1"
@@ -176,15 +288,27 @@ default_profile = "personal"
[profiles.personal] [profiles.personal]
name = "personal" name = "personal"
user_name = "John Doe" user_name = "Your Name"
user_email = "john@example.com" user_email = "your.email@example.com"
description = "个人项目"
is_work = false
[profiles.work] [profiles.work]
name = "work" name = "work"
user_name = "John Doe" user_name = "Your Name"
user_email = "john@company.com" user_email = "your.name@company.com"
description = "工作项目"
is_work = true is_work = true
organization = "Acme Corp" organization = "Your Company"
[profiles.work.ssh]
private_key_path = "/home/user/.ssh/id_rsa_work"
agent_forwarding = true
[profiles.work.gpg]
key_id = "YOUR_GPG_KEY_ID"
program = "gpg"
use_agent = true
[llm] [llm]
provider = "ollama" provider = "ollama"
@@ -200,19 +324,50 @@ model = "llama2"
model = "gpt-4" model = "gpt-4"
base_url = "https://api.openai.com/v1" base_url = "https://api.openai.com/v1"
[llm.anthropic]
model = "claude-3-sonnet-20240229"
[llm.kimi]
model = "moonshot-v1-8k"
[llm.deepseek]
model = "deepseek-chat"
[llm.openrouter]
model = "openai/gpt-4"
[commit] [commit]
format = "conventional" format = "conventional"
auto_generate = true auto_generate = true
allow_empty = false
gpg_sign = false
max_subject_length = 100 max_subject_length = 100
require_scope = false
require_body = false
body_required_types = ["feat", "fix"]
[tag] [tag]
version_prefix = "v" version_prefix = "v"
auto_generate = true auto_generate = true
gpg_sign = false
include_changelog = true
[changelog] [changelog]
path = "CHANGELOG.md" path = "CHANGELOG.md"
auto_generate = true auto_generate = true
format = "keep-a-changelog"
include_hashes = false
include_authors = false
group_by_type = true group_by_type = true
[theme]
colors = true
icons = true
date_format = "%Y-%m-%d"
[repo_profiles]
"/path/to/work/project" = "work"
"/path/to/personal/project" = "personal"
``` ```
## 环境变量 ## 环境变量
@@ -229,14 +384,32 @@ group_by_type = true
# 查看当前配置 # 查看当前配置
quicommit config list quicommit config list
# 显示配置详情
quicommit config show
# 编辑配置文件
quicommit config edit
# 设置配置值
quicommit config set llm.provider ollama
# 获取配置值
quicommit config get llm.provider
# 测试LLM连接 # 测试LLM连接
quicommit config test-llm quicommit config test-llm
# 列出可用模型 # 列出可用模型
quicommit config list-models quicommit config list-models
# 编辑配置文件 # 导出配置
quicommit config edit quicommit config export -o config-backup.toml
# 导入配置
quicommit config import -i config-backup.toml
# 重置配置
quicommit config reset --force
``` ```
## 贡献 ## 贡献
@@ -255,8 +428,8 @@ quicommit config edit
```bash ```bash
# 克隆仓库 # 克隆仓库
git clone https://github.com/YOUR_USERNAME/quicommit.git git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
cd quicommit cd QuiCommit
# 安装依赖 # 安装依赖
cargo fetch cargo fetch
@@ -284,11 +457,36 @@ cargo fmt --check
``` ```
src/ src/
├── commands/ # CLI命令实现 ├── commands/ # CLI命令实现
│ ├── commit.rs
│ ├── tag.rs
│ ├── changelog.rs
│ ├── profile.rs
│ ├── config.rs
│ └── init.rs
├── config/ # 配置管理 ├── config/ # 配置管理
│ ├── manager.rs
│ └── profile.rs
├── generator/ # AI内容生成 ├── generator/ # AI内容生成
├── git/ # Git操作封装 ├── git/ # Git操作封装
│ ├── commit.rs
│ ├── tag.rs
│ └── changelog.rs
├── llm/ # LLM提供商实现 ├── llm/ # LLM提供商实现
└── utils/ # 工具函数 │ ├── ollama.rs
│ ├── openai.rs
│ ├── anthropic.rs
│ ├── kimi.rs
│ ├── deepseek.rs
│ └── openrouter.rs
├── i18n/ # 国际化支持
│ ├── messages.rs
│ └── translator.rs
├── utils/ # 工具函数
│ ├── validators.rs
│ ├── formatter.rs
│ ├── crypto.rs
│ └── editor.rs
└── main.rs # 程序入口
``` ```
## 许可证 ## 许可证

View File

@@ -5,10 +5,11 @@ use colored::Colorize;
use dialoguer::{Confirm, Input}; use dialoguer::{Confirm, Input};
use std::path::PathBuf; use std::path::PathBuf;
use crate::config::manager::ConfigManager; use crate::config::{Language, manager::ConfigManager};
use crate::generator::ContentGenerator; use crate::generator::ContentGenerator;
use crate::git::find_repo; use crate::git::find_repo;
use crate::git::{changelog::*, CommitInfo, GitRepo}; use crate::git::{changelog::*, CommitInfo};
use crate::i18n::{Messages, translate_changelog_category};
/// Generate changelog /// Generate changelog
#[derive(Parser)] #[derive(Parser)]
@@ -67,6 +68,8 @@ impl ChangelogCommand {
let repo = find_repo(std::env::current_dir()?.as_path())?; let repo = find_repo(std::env::current_dir()?.as_path())?;
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
// Initialize changelog if requested // Initialize changelog if requested
if self.init { if self.init {
@@ -75,7 +78,7 @@ impl ChangelogCommand {
.unwrap_or_else(|| PathBuf::from(&config.changelog.path)); .unwrap_or_else(|| PathBuf::from(&config.changelog.path));
init_changelog(&path)?; init_changelog(&path)?;
println!("{} Initialized changelog at {:?}", "".green(), path); println!("{}", messages.initialized_changelog(&format!("{:?}", path)));
return Ok(()); return Ok(());
} }
@@ -98,28 +101,28 @@ impl ChangelogCommand {
v.clone() v.clone()
} else if !self.yes { } else if !self.yes {
Input::new() Input::new()
.with_prompt("Version") .with_prompt(messages.version())
.default("Unreleased".to_string()) .default(messages.unreleased().to_string())
.interact_text()? .interact_text()?
} else { } else {
"Unreleased".to_string() messages.unreleased().to_string()
}; };
// Get commits // Get commits
println!("{} Fetching commits...", "".blue()); println!("{}", messages.fetching_commits());
let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?; let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?;
if commits.is_empty() { if commits.is_empty() {
bail!("No commits found in the specified range"); bail!("{}", messages.no_commits_found());
} }
println!("{} Found {} commits", "".green(), commits.len()); println!("{}", messages.found_commits(commits.len()));
// Generate changelog // Generate changelog
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) { let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) {
self.generate_with_ai(&repo, &version, &commits).await? self.generate_with_ai(&version, &commits, &messages).await?
} else { } else {
self.generate_with_template(format, &version, &commits)? self.generate_with_template(format, &version, &commits, language)?
}; };
// Output or write // Output or write
@@ -133,7 +136,7 @@ impl ChangelogCommand {
// Preview // Preview
if !self.yes { if !self.yes {
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
println!("{}", "Changelog preview:".bold()); println!("{}", messages.changelog_preview().bold());
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
// Show first 20 lines // Show first 20 lines
let preview: String = changelog.lines().take(20).collect::<Vec<_>>().join("\n"); let preview: String = changelog.lines().take(20).collect::<Vec<_>>().join("\n");
@@ -144,12 +147,12 @@ impl ChangelogCommand {
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt(&format!("Write to {:?}?", output_path)) .with_prompt(&messages.write_to_file(&format!("{:?}", output_path)))
.default(true) .default(true)
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", "Cancelled.".yellow()); println!("{}", messages.cancelled().yellow());
return Ok(()); return Ok(());
} }
} }
@@ -163,24 +166,25 @@ impl ChangelogCommand {
std::fs::write(&output_path, changelog)?; std::fs::write(&output_path, changelog)?;
} }
println!("{} Changelog written to {:?}", "".green(), output_path); println!("{} {:?}", messages.changelog_written(), output_path);
Ok(()) Ok(())
} }
async fn generate_with_ai( async fn generate_with_ai(
&self, &self,
repo: &GitRepo,
version: &str, version: &str,
commits: &[CommitInfo], commits: &[CommitInfo],
messages: &Messages,
) -> Result<String> { ) -> Result<String> {
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
println!("{} AI is generating changelog...", "🤖"); println!("{}", messages.ai_generating_changelog());
let generator = ContentGenerator::new(&config.llm).await?; let generator = ContentGenerator::new(&config.llm).await?;
generator.generate_changelog_entry(version, commits).await generator.generate_changelog_entry(version, commits, language).await
} }
fn generate_with_template( fn generate_with_template(
@@ -188,12 +192,43 @@ impl ChangelogCommand {
format: ChangelogFormat, format: ChangelogFormat,
version: &str, version: &str,
commits: &[CommitInfo], commits: &[CommitInfo],
language: Language,
) -> Result<String> { ) -> Result<String> {
let manager = ConfigManager::new()?;
let generator = ChangelogGenerator::new() let generator = ChangelogGenerator::new()
.format(format) .format(format)
.include_hashes(self.include_hashes) .include_hashes(self.include_hashes)
.include_authors(self.include_authors); .include_authors(self.include_authors);
generator.generate(version, Utc::now(), commits) let changelog = generator.generate(version, Utc::now(), commits)?;
// Translate changelog categories if configured
if !manager.keep_changelog_types_english() {
Ok(self.translate_changelog_categories(&changelog, language))
} else {
Ok(changelog)
}
}
fn translate_changelog_categories(&self, changelog: &str, language: Language) -> String {
let translated = changelog
.lines()
.map(|line| {
if line.starts_with("## ") || line.starts_with("### ") {
let category = line.trim_start_matches("## ").trim_start_matches("### ");
let translated_category = translate_changelog_category(category, language, false);
if line.starts_with("## ") {
format!("## {}", translated_category)
} else {
format!("### {}", translated_category)
}
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
translated
} }
} }

View File

@@ -3,18 +3,19 @@ use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use crate::config::manager::ConfigManager; use crate::config::{Language, manager::ConfigManager};
use crate::config::CommitFormat; use crate::config::CommitFormat;
use crate::generator::ContentGenerator; use crate::generator::ContentGenerator;
use crate::git::{find_repo, GitRepo}; use crate::git::{find_repo, GitRepo};
use crate::git::commit::{CommitBuilder, create_date_commit_message}; use crate::git::commit::{CommitBuilder, create_date_commit_message};
use crate::i18n::Messages;
use crate::utils::validators::get_commit_types; use crate::utils::validators::get_commit_types;
/// Generate and execute conventional commits /// Generate and execute conventional commits
#[derive(Parser)] #[derive(Parser)]
pub struct CommitCommand { pub struct CommitCommand {
/// Commit type /// Commit type
#[arg(short, long)] #[arg(long)]
commit_type: Option<String>, commit_type: Option<String>,
/// Commit scope /// Commit scope
@@ -38,7 +39,7 @@ pub struct CommitCommand {
date: bool, date: bool,
/// Manual input (skip AI generation) /// Manual input (skip AI generation)
#[arg(short, long)] #[arg(long)]
manual: bool, manual: bool,
/// Sign the commit /// Sign the commit
@@ -72,6 +73,14 @@ pub struct CommitCommand {
/// Skip interactive prompts /// Skip interactive prompts
#[arg(short = 'y', long)] #[arg(short = 'y', long)]
yes: bool, yes: bool,
/// Push after committing
#[arg(long)]
push: bool,
/// Remote to push to
#[arg(long, default_value = "origin")]
remote: String,
} }
impl CommitCommand { impl CommitCommand {
@@ -79,15 +88,17 @@ impl CommitCommand {
// Find git repository // Find git repository
let repo = find_repo(std::env::current_dir()?.as_path())?; let repo = find_repo(std::env::current_dir()?.as_path())?;
// Check for changes
let status = repo.status_summary()?;
if status.clean && !self.amend {
bail!("No changes to commit. Working tree is clean.");
}
// Load configuration // Load configuration
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
// Check for changes
let status = repo.status_summary()?;
if status.clean && !self.amend {
bail!("{}", messages.no_changes());
}
// Determine commit format // Determine commit format
let format = if self.conventional { let format = if self.conventional {
@@ -98,10 +109,23 @@ impl CommitCommand {
config.commit.format config.commit.format
}; };
// Auto-add if no files are staged and there are unstaged/untracked changes
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
println!("{}", messages.auto_stage_changes().yellow());
repo.stage_all()?;
println!("{}", messages.staged_all().green());
// Re-check status after staging to ensure changes are detected
let new_status = repo.status_summary()?;
if new_status.staged == 0 {
bail!("Failed to stage changes. Please try running 'git add -A' manually.");
}
}
// Stage all if requested // Stage all if requested
if self.all { if self.all {
repo.stage_all()?; repo.stage_all()?;
println!("{}", "✓ Staged all changes".green()); println!("{}", messages.staged_all().green());
} }
// Generate or build commit message // Generate or build commit message
@@ -111,12 +135,12 @@ impl CommitCommand {
} else if self.manual || self.message.is_some() { } else if self.manual || self.message.is_some() {
// Manual commit // Manual commit
self.create_manual_commit(format)? self.create_manual_commit(format)?
} else if config.commit.auto_generate && !self.yes { } else if config.commit.auto_generate {
// AI-generated commit // AI-generated commit
self.generate_commit(&repo, format).await? self.generate_commit(&repo, format, &messages).await?
} else { } else {
// Interactive commit creation // Interactive commit creation
self.create_interactive_commit(format).await? self.create_interactive_commit(format, &messages).await?
}; };
// Validate message // Validate message
@@ -132,39 +156,62 @@ impl CommitCommand {
// Show commit preview // Show commit preview
if !self.yes { if !self.yes {
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
println!("{}", "Commit preview:".bold()); println!("{}", messages.commit_preview().bold());
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
println!("{}", commit_message); println!("{}", commit_message);
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt("Do you want to proceed with this commit?") .with_prompt(messages.proceed_commit())
.default(true) .default(true)
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", "Commit cancelled.".yellow()); println!("{}", messages.commit_cancelled().yellow());
return Ok(()); return Ok(());
} }
} }
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{} {}", messages.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{} {}", messages.dry_run(), "- commit not created.".yellow());
return Ok(());
}
CommitBuilder::new()
.message(&commit_message)
.sign(self.sign)
.execute(&repo)?
}; };
if let Some(commit_oid) = result { if let Some(commit_oid) = result {
println!("{} {}", "✓ Created commit".green().bold(), commit_oid.to_string()[..8].to_string().cyan()); println!("{} {}", messages.commit_created().green().bold(), commit_oid.to_string()[..8].to_string().cyan());
} else { } else {
println!("{} {}", "✓ Amended commit".green().bold(), "successfully"); println!("{} {}", messages.commit_amended().green().bold(), "successfully");
}
// Push after commit if requested or ask user
if self.push {
println!("\n{}", messages.pushing_commit(&self.remote));
repo.push(&self.remote, "HEAD")?;
println!("{}", messages.pushed_commit(&self.remote));
} else if !self.yes && !self.dry_run {
let should_push = Confirm::new()
.with_prompt(messages.push_after_commit())
.default(false)
.interact()?;
if should_push {
println!("\n{}", messages.pushing_commit(&self.remote));
repo.push(&self.remote, "HEAD")?;
println!("{}", messages.pushed_commit(&self.remote));
}
} }
Ok(()) Ok(())
@@ -176,12 +223,22 @@ impl CommitCommand {
} }
fn create_manual_commit(&self, format: CommitFormat) -> Result<String> { 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() let description = self.message.clone()
.ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?; .ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?;
// Try to extract commit type from message if not provided
let commit_type = if let Some(ref ct) = self.commit_type {
ct.clone()
} else {
// Parse from conventional commit format: "type: description"
description
.split(':')
.next()
.unwrap_or("feat")
.trim()
.to_string()
};
let builder = CommitBuilder::new() let builder = CommitBuilder::new()
.commit_type(commit_type) .commit_type(commit_type)
.description(description) .description(description)
@@ -193,7 +250,7 @@ impl CommitCommand {
builder.build_message() builder.build_message()
} }
async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat) -> Result<String> { async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat, messages: &Messages) -> Result<String> {
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
@@ -201,53 +258,58 @@ impl CommitCommand {
let generator = ContentGenerator::new(&config.llm).await let generator = ContentGenerator::new(&config.llm).await
.context("Failed to initialize LLM. Use --manual for manual commit.")?; .context("Failed to initialize LLM. Use --manual for manual commit.")?;
println!("{} AI is analyzing your changes...", "🤖".to_string()); println!("{}", messages.ai_analyzing());
let language_str = &config.language.output_language;
let language = Language::from_str(language_str).unwrap_or(Language::English);
let generated = if self.yes { let generated = if self.yes {
generator.generate_commit_from_repo(repo, format).await? // Non-interactive mode: generate directly
generator.generate_commit_from_repo(repo, format, language).await?
} else { } else {
generator.generate_commit_interactive(repo, format).await? // Interactive mode: allow user to review and regenerate
generator.generate_commit_interactive(repo, format, language).await?
}; };
Ok(generated.to_conventional()) Ok(generated.to_conventional())
} }
async fn create_interactive_commit(&self, format: CommitFormat) -> Result<String> { async fn create_interactive_commit(&self, format: CommitFormat, messages: &Messages) -> Result<String> {
let types = get_commit_types(format == CommitFormat::Commitlint); let types = get_commit_types(format == CommitFormat::Commitlint);
// Select type // Select type
let type_idx = Select::new() let type_idx = Select::new()
.with_prompt("Select commit type") .with_prompt(messages.select_commit_type())
.items(types) .items(types)
.interact()?; .interact()?;
let commit_type = types[type_idx].to_string(); let commit_type = types[type_idx].to_string();
// Enter scope (optional) // Enter scope (optional)
let scope: String = Input::new() let scope: String = Input::new()
.with_prompt("Scope (optional, press Enter to skip)") .with_prompt(messages.scope_optional())
.allow_empty(true) .allow_empty(true)
.interact_text()?; .interact_text()?;
let scope = if scope.is_empty() { None } else { Some(scope) }; let scope = if scope.is_empty() { None } else { Some(scope) };
// Enter description // Enter description
let description: String = Input::new() let description: String = Input::new()
.with_prompt("Description") .with_prompt(messages.description())
.interact_text()?; .interact_text()?;
// Breaking change // Breaking change
let breaking = Confirm::new() let breaking = Confirm::new()
.with_prompt("Is this a breaking change?") .with_prompt(messages.breaking_change())
.default(false) .default(false)
.interact()?; .interact()?;
// Add body // Add body
let add_body = Confirm::new() let add_body = Confirm::new()
.with_prompt("Add body to commit?") .with_prompt(messages.add_body())
.default(false) .default(false)
.interact()?; .interact()?;
let body = if add_body { let body = if add_body {
let body_text = crate::utils::editor::edit_content("Enter commit body...")?; let body_text = crate::utils::editor::edit_content(messages.enter_commit_body())?;
if body_text.trim().is_empty() { if body_text.trim().is_empty() {
None None
} else { } else {

View File

@@ -3,7 +3,7 @@ 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::{Language, manager::ConfigManager};
use crate::config::CommitFormat; use crate::config::CommitFormat;
/// Mask API key with asterisks for security /// Mask API key with asterisks for security
@@ -147,6 +147,24 @@ enum ConfigSubcommand {
path: String, path: String,
}, },
/// Set output language
SetLanguage {
/// Language code (en, zh, ja, ko, es, fr, de)
language: Option<String>,
},
/// Set whether to keep commit types in English
SetKeepTypesEnglish {
/// Keep types in English (true/false)
keep: bool,
},
/// Set whether to keep changelog types in English
SetKeepChangelogTypesEnglish {
/// Keep types in English (true/false)
keep: bool,
},
/// Reset configuration to defaults /// Reset configuration to defaults
Reset { Reset {
/// Skip confirmation /// Skip confirmation
@@ -196,6 +214,9 @@ impl ConfigCommand {
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format).await, Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format).await,
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await, Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await,
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await, Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await,
Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref()).await,
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep).await,
Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep).await,
Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await, Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await,
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await, Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await,
Some(ConfigSubcommand::Import { file }) => self.import_config(file).await, Some(ConfigSubcommand::Import { file }) => self.import_config(file).await,
@@ -268,6 +289,12 @@ impl ConfigCommand {
println!(" GPG sign: {}", if config.tag.gpg_sign { "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!(" Include changelog: {}", if config.tag.include_changelog { "yes".green() } else { "no".red() });
println!("\n{}", "Language Configuration:".bold());
let language = manager.get_language().unwrap_or(Language::English);
println!(" Output language: {}", language.display_name().cyan());
println!(" Keep commit types in English: {}", if manager.keep_types_english() { "yes".green() } else { "no".red() });
println!(" Keep changelog types in English: {}", if manager.keep_changelog_types_english() { "yes".green() } else { "no".red() });
println!("\n{}", "Changelog Configuration:".bold()); println!("\n{}", "Changelog Configuration:".bold());
println!(" Path: {}", config.changelog.path); println!(" Path: {}", config.changelog.path);
println!(" Auto-generate: {}", if config.changelog.auto_generate { "yes".green() } else { "no".red() }); println!(" Auto-generate: {}", if config.changelog.auto_generate { "yes".green() } else { "no".red() });
@@ -706,6 +733,54 @@ impl ConfigCommand {
Ok(()) Ok(())
} }
async fn set_language(&self, language: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
let language_code = if let Some(lang) = language {
lang.to_string()
} else {
let languages = vec![
Language::English,
Language::Chinese,
Language::Japanese,
Language::Korean,
Language::Spanish,
Language::French,
Language::German,
];
let language_names: Vec<String> = languages.iter().map(|l| l.display_name().to_string()).collect();
let idx = Select::new()
.with_prompt("Select language")
.items(&language_names)
.default(0)
.interact()?;
languages[idx].to_code().to_string()
};
manager.set_output_language(language_code.clone());
manager.save()?;
println!("{} Set output language to {}", "".green(), language_code.cyan());
Ok(())
}
async fn set_keep_types_english(&self, keep: bool) -> Result<()> {
let mut manager = ConfigManager::new()?;
manager.set_keep_types_english(keep);
manager.save()?;
let status = if keep { "enabled" } else { "disabled" };
println!("{} Keep commit types in English: {}", "".green(), status);
Ok(())
}
async fn set_keep_changelog_types_english(&self, keep: bool) -> Result<()> {
let mut manager = ConfigManager::new()?;
manager.set_keep_changelog_types_english(keep);
manager.save()?;
let status = if keep { "enabled" } else { "disabled" };
println!("{} Keep changelog types in English: {}", "".green(), status);
Ok(())
}
async fn reset(&self, force: bool) -> Result<()> { async fn reset(&self, force: bool) -> Result<()> {
if !force { if !force {
let confirm = Confirm::new() let confirm = Confirm::new()
@@ -802,6 +877,105 @@ impl ConfigCommand {
bail!("OpenAI API key not configured"); bail!("OpenAI API key not configured");
} }
} }
"anthropic" => {
if let Some(ref key) = config.llm.anthropic.api_key {
let client = crate::llm::AnthropicClient::new(
key,
&config.llm.anthropic.model,
)?;
println!("Fetching available models from Anthropic...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.anthropic.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("Anthropic API key not configured");
}
}
"kimi" => {
if let Some(ref key) = config.llm.kimi.api_key {
let client = crate::llm::KimiClient::with_base_url(
key,
&config.llm.kimi.model,
&config.llm.kimi.base_url,
)?;
println!("Fetching available models from Kimi...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.kimi.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("Kimi API key not configured");
}
}
"deepseek" => {
if let Some(ref key) = config.llm.deepseek.api_key {
let client = crate::llm::DeepSeekClient::with_base_url(
key,
&config.llm.deepseek.model,
&config.llm.deepseek.base_url,
)?;
println!("Fetching available models from DeepSeek...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.deepseek.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("DeepSeek API key not configured");
}
}
"openrouter" => {
if let Some(ref key) = config.llm.openrouter.api_key {
let client = crate::llm::OpenRouterClient::with_base_url(
key,
&config.llm.openrouter.model,
&config.llm.openrouter.base_url,
)?;
println!("Fetching available models from OpenRouter...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.openrouter.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("OpenRouter API key not configured");
}
}
provider => { provider => {
println!("Listing models not supported for provider: {}", provider); println!("Listing models not supported for provider: {}", provider);
} }
@@ -830,7 +1004,7 @@ impl ConfigCommand {
fn main() { fn main() {
+ println!("Hello, World!"); + println!("Hello, World!");
}"#; }"#;
match client.generate_commit_message(sample_diff, crate::config::CommitFormat::Conventional).await { match client.generate_commit_message(sample_diff, crate::config::CommitFormat::Conventional, crate::config::Language::English).await {
Ok(response) => { Ok(response) => {
println!("{} Generation test passed", "".green()); println!("{} Generation test passed", "".green());
println!("Response: {}", response.description.dimmed()); println!("Response: {}", response.description.dimmed());

View File

@@ -3,9 +3,10 @@ use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use crate::config::{GitProfile}; use crate::config::{GitProfile, Language};
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
use crate::config::profile::{GpgConfig, SshConfig}; use crate::config::profile::{GpgConfig, SshConfig};
use crate::i18n::Messages;
use crate::utils::validators::validate_email; use crate::utils::validators::validate_email;
/// Initialize quicommit configuration /// Initialize quicommit configuration
@@ -22,7 +23,9 @@ pub struct InitCommand {
impl InitCommand { impl InitCommand {
pub async fn execute(&self) -> Result<()> { pub async fn execute(&self) -> Result<()> {
println!("{}", "🚀 Initializing QuiCommit...".bold().cyan()); // Start with English messages for initialization
let messages = Messages::new(Language::English);
println!("{}", messages.initializing().bold().cyan());
let config_path = crate::config::AppConfig::default_path()?; let config_path = crate::config::AppConfig::default_path()?;
@@ -57,9 +60,13 @@ impl InitCommand {
manager.save()?; manager.save()?;
println!("{}", "✅ QuiCommit initialized successfully!".bold().green()); // Get configured language for final messages
println!("\nConfig file: {}", config_path.display()); let language = manager.get_language().unwrap_or(Language::English);
println!("\nNext steps:"); let messages = Messages::new(language);
println!("{}", messages.init_success().bold().green());
println!("\n{}: {}", messages.config_file(), config_path.display());
println!("\n{}:", messages.next_steps());
println!(" 1. Create a profile: {}", "quicommit profile add".cyan()); println!(" 1. Create a profile: {}", "quicommit profile add".cyan());
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan()); println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
println!(" 3. Start committing: {}", "quicommit commit".cyan()); println!(" 3. Start committing: {}", "quicommit commit".cyan());
@@ -90,11 +97,35 @@ impl InitCommand {
} }
async fn interactive_setup(&self, manager: &mut ConfigManager) -> Result<()> { async fn interactive_setup(&self, manager: &mut ConfigManager) -> Result<()> {
println!("\n{}", "Let's set up your first profile:".bold()); let messages = Messages::new(Language::English);
println!("\n{}", messages.setup_profile().bold());
// Language selection
println!("\n{}", messages.select_output_language().bold());
let languages = vec![
Language::English,
Language::Chinese,
Language::Japanese,
Language::Korean,
Language::Spanish,
Language::French,
Language::German,
];
let language_names: Vec<String> = languages.iter().map(|l| l.display_name().to_string()).collect();
let language_idx = Select::new()
.items(&language_names)
.default(0)
.interact()?;
let selected_language = languages[language_idx];
manager.set_output_language(selected_language.to_code().to_string());
// Update messages to selected language
let messages = Messages::new(selected_language);
// Profile name // Profile name
let profile_name: String = Input::new() let profile_name: String = Input::new()
.with_prompt("Profile name") .with_prompt(messages.profile_name())
.default("personal".to_string()) .default("personal".to_string())
.interact_text()?; .interact_text()?;
@@ -110,12 +141,12 @@ impl InitCommand {
.unwrap_or_default(); .unwrap_or_default();
let user_name: String = Input::new() let user_name: String = Input::new()
.with_prompt("Git user name") .with_prompt(messages.git_user_name())
.default(default_name) .default(default_name)
.interact_text()?; .interact_text()?;
let user_email: String = Input::new() let user_email: String = Input::new()
.with_prompt("Git user email") .with_prompt(messages.git_user_email())
.default(default_email) .default(default_email)
.validate_with(|input: &String| { .validate_with(|input: &String| {
validate_email(input).map_err(|e| e.to_string()) validate_email(input).map_err(|e| e.to_string())
@@ -123,18 +154,18 @@ impl InitCommand {
.interact_text()?; .interact_text()?;
let description: String = Input::new() let description: String = Input::new()
.with_prompt("Profile description (optional)") .with_prompt(messages.profile_description())
.allow_empty(true) .allow_empty(true)
.interact_text()?; .interact_text()?;
let is_work = Confirm::new() let is_work = Confirm::new()
.with_prompt("Is this a work profile?") .with_prompt(messages.is_work_profile())
.default(false) .default(false)
.interact()?; .interact()?;
let organization = if is_work { let organization = if is_work {
Some(Input::new() Some(Input::new()
.with_prompt("Organization/Company name") .with_prompt(messages.organization_name())
.interact_text()?) .interact_text()?)
} else { } else {
None None
@@ -142,24 +173,24 @@ impl InitCommand {
// SSH configuration // SSH configuration
let setup_ssh = Confirm::new() let setup_ssh = Confirm::new()
.with_prompt("Configure SSH key?") .with_prompt(messages.configure_ssh())
.default(false) .default(false)
.interact()?; .interact()?;
let ssh_config = if setup_ssh { let ssh_config = if setup_ssh {
Some(self.setup_ssh_interactive().await?) Some(self.setup_ssh_interactive(&messages).await?)
} else { } else {
None None
}; };
// GPG configuration // GPG configuration
let setup_gpg = Confirm::new() let setup_gpg = Confirm::new()
.with_prompt("Configure GPG signing?") .with_prompt(messages.configure_gpg())
.default(false) .default(false)
.interact()?; .interact()?;
let gpg_config = if setup_gpg { let gpg_config = if setup_gpg {
Some(self.setup_gpg_interactive().await?) Some(self.setup_gpg_interactive(&messages).await?)
} else { } else {
None None
}; };
@@ -184,8 +215,15 @@ impl InitCommand {
manager.set_default_profile(Some(profile_name))?; manager.set_default_profile(Some(profile_name))?;
// LLM provider selection // LLM provider selection
println!("\n{}", "Select your preferred LLM provider:".bold()); println!("\n{}", messages.select_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 +233,9 @@ impl InitCommand {
0 => "ollama", 0 => "ollama",
1 => "openai", 1 => "openai",
2 => "anthropic", 2 => "anthropic",
3 => "kimi",
4 => "deepseek",
5 => "openrouter",
_ => "ollama", _ => "ollama",
}; };
@@ -203,20 +244,35 @@ impl InitCommand {
// Configure API key if needed // Configure API key if needed
if provider == "openai" { if provider == "openai" {
let api_key: String = Input::new() let api_key: String = Input::new()
.with_prompt("OpenAI API key") .with_prompt(messages.openai_api_key())
.interact_text()?; .interact_text()?;
manager.set_openai_api_key(api_key); manager.set_openai_api_key(api_key);
} else if provider == "anthropic" { } else if provider == "anthropic" {
let api_key: String = Input::new() let api_key: String = Input::new()
.with_prompt("Anthropic API key") .with_prompt(messages.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(messages.kimi_api_key())
.interact_text()?;
manager.set_kimi_api_key(api_key);
} else if provider == "deepseek" {
let api_key: String = Input::new()
.with_prompt(messages.deepseek_api_key())
.interact_text()?;
manager.set_deepseek_api_key(api_key);
} else if provider == "openrouter" {
let api_key: String = Input::new()
.with_prompt(messages.openrouter_api_key())
.interact_text()?;
manager.set_openrouter_api_key(api_key);
} }
Ok(()) Ok(())
} }
async fn setup_ssh_interactive(&self) -> Result<SshConfig> { async fn setup_ssh_interactive(&self, messages: &Messages) -> Result<SshConfig> {
use std::path::PathBuf; use std::path::PathBuf;
let ssh_dir = dirs::home_dir() let ssh_dir = dirs::home_dir()
@@ -224,17 +280,17 @@ impl InitCommand {
.unwrap_or_else(|| PathBuf::from("~/.ssh")); .unwrap_or_else(|| PathBuf::from("~/.ssh"));
let key_path: String = Input::new() let key_path: String = Input::new()
.with_prompt("SSH private key path") .with_prompt(messages.ssh_private_key_path())
.default(ssh_dir.join("id_rsa").display().to_string()) .default(ssh_dir.join("id_rsa").display().to_string())
.interact_text()?; .interact_text()?;
let has_passphrase = Confirm::new() let has_passphrase = Confirm::new()
.with_prompt("Does this key have a passphrase?") .with_prompt(messages.has_passphrase())
.default(false) .default(false)
.interact()?; .interact()?;
let passphrase = if has_passphrase { let passphrase = if has_passphrase {
Some(crate::utils::password_input("SSH key passphrase")?) Some(crate::utils::password_input(messages.ssh_key_passphrase())?)
} else { } else {
None None
}; };
@@ -249,13 +305,13 @@ impl InitCommand {
}) })
} }
async fn setup_gpg_interactive(&self) -> Result<GpgConfig> { async fn setup_gpg_interactive(&self, messages: &Messages) -> Result<GpgConfig> {
let key_id: String = Input::new() let key_id: String = Input::new()
.with_prompt("GPG key ID") .with_prompt(messages.gpg_key_id())
.interact_text()?; .interact_text()?;
let use_agent = Confirm::new() let use_agent = Confirm::new()
.with_prompt("Use GPG agent?") .with_prompt(messages.use_gpg_agent())
.default(true) .default(true)
.interact()?; .interact()?;

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

@@ -4,12 +4,13 @@ use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use semver::Version; use semver::Version;
use crate::config::manager::ConfigManager; use crate::config::{Language, manager::ConfigManager};
use crate::git::{find_repo, GitRepo}; use crate::git::{find_repo, GitRepo};
use crate::generator::ContentGenerator; use crate::generator::ContentGenerator;
use crate::git::tag::{ use crate::git::tag::{
bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump, bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump,
}; };
use crate::i18n::Messages;
/// Generate and create Git tags /// Generate and create Git tags
#[derive(Parser)] #[derive(Parser)]
@@ -64,6 +65,8 @@ impl TagCommand {
let repo = find_repo(std::env::current_dir()?.as_path())?; let repo = find_repo(std::env::current_dir()?.as_path())?;
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
let messages = Messages::new(language);
// Determine tag name // Determine tag name
let tag_name = if let Some(name) = &self.name { let tag_name = if let Some(name) = &self.name {
@@ -80,7 +83,7 @@ impl TagCommand {
format!("{}{}", prefix, new_version) format!("{}{}", prefix, new_version)
} else { } else {
// Interactive mode // Interactive mode
self.select_version_interactive(&repo, &config.tag.version_prefix).await? self.select_version_interactive(&repo, &config.tag.version_prefix, &messages).await?
}; };
// Validate tag name (if it looks like a version) // Validate tag name (if it looks like a version)
@@ -96,7 +99,7 @@ impl TagCommand {
.interact()?; .interact()?;
if !proceed { if !proceed {
bail!("Tag creation cancelled"); bail!("{}", messages.tag_cancelled());
} }
} }
} }
@@ -108,16 +111,16 @@ impl TagCommand {
} else if let Some(msg) = &self.message { } else if let Some(msg) = &self.message {
Some(msg.clone()) Some(msg.clone())
} else if self.generate || (config.tag.auto_generate && !self.yes) { } else if self.generate || (config.tag.auto_generate && !self.yes) {
Some(self.generate_tag_message(&repo, &tag_name).await?) Some(self.generate_tag_message(&repo, &tag_name, &messages).await?)
} else if !self.yes { } else if !self.yes {
Some(self.input_message_interactive(&tag_name)?) Some(self.input_message_interactive(&tag_name, &messages)?)
} else { } else {
Some(format!("Release {}", tag_name)) Some(format!("Release {}", tag_name))
}; };
// Show preview // Show preview
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
println!("{}", "Tag preview:".bold()); println!("{}", messages.tag_preview().bold());
println!("{}", "".repeat(60)); println!("{}", "".repeat(60));
println!("Name: {}", tag_name.cyan()); println!("Name: {}", tag_name.cyan());
if let Some(ref msg) = message { if let Some(ref msg) = message {
@@ -129,22 +132,21 @@ impl TagCommand {
if !self.yes { if !self.yes {
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt("Create this tag?") .with_prompt(messages.create_tag())
.default(true) .default(true)
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", "Tag creation cancelled.".yellow()); println!("{}", messages.tag_cancelled().yellow());
return Ok(()); return Ok(());
} }
} }
if self.dry_run { if self.dry_run {
println!("\n{}", "Dry run - tag not created.".yellow()); println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow());
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)
@@ -154,41 +156,52 @@ impl TagCommand {
builder.execute(&repo)?; builder.execute(&repo)?;
println!("{} Created tag {}", "".green(), tag_name.cyan()); println!("{} {}", messages.tag_created().green(), tag_name.cyan());
// Push if requested // Push if requested or ask user
if self.push { if self.push {
println!("{} Pushing tag to {}...", "".blue(), &self.remote); println!("{}", messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{} Pushed tag to {}", "".green(), &self.remote); println!("{}", messages.pushed_tag(&self.remote));
} else if !self.yes && !self.dry_run {
let should_push = Confirm::new()
.with_prompt(messages.push_after_tag())
.default(false)
.interact()?;
if should_push {
println!("{}", messages.pushing_tag(&self.remote));
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{}", messages.pushed_tag(&self.remote));
}
} }
Ok(()) Ok(())
} }
async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str) -> Result<String> { async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str, messages: &Messages) -> Result<String> {
loop { loop {
let latest = get_latest_version(repo, prefix)?; let latest = get_latest_version(repo, prefix)?;
println!("\n{}", "Version selection:".bold()); println!("\n{}", messages.version_selection().bold());
if let Some(ref version) = latest { if let Some(ref version) = latest {
println!("Latest version: {}{}", prefix, version); println!("{} {}{}", messages.latest_version(), prefix, version);
} else { } else {
println!("No existing version tags found"); println!("{}", messages.no_existing_version_tags());
} }
let options = vec![ let options = vec![
"Auto-detect bump from commits", messages.auto_detect_bump(),
"Bump major version", messages.bump_major_version(),
"Bump minor version", messages.bump_minor_version(),
"Bump patch version", messages.bump_patch_version(),
"Enter custom version", messages.enter_custom_version(),
"Enter custom tag name", messages.enter_custom_tag_name(),
]; ];
let selection = Select::new() let selection = Select::new()
.with_prompt("Select option") .with_prompt(messages.select_option())
.items(&options) .items(&options)
.default(0) .default(0)
.interact()?; .interact()?;
@@ -202,10 +215,10 @@ impl TagCommand {
.map(|v| bump_version(v, bump, None)) .map(|v| bump_version(v, bump, None))
.unwrap_or_else(|| Version::new(0, 1, 0)); .unwrap_or_else(|| Version::new(0, 1, 0));
println!("Suggested bump: {:?}{}{}", bump, prefix, version); println!("{} {:?}{}{}", messages.suggested_bump(), bump, prefix, version);
let confirm = Confirm::new() let confirm = Confirm::new()
.with_prompt("Use this version?") .with_prompt(messages.use_this_version())
.default(true) .default(true)
.interact()?; .interact()?;
@@ -234,14 +247,14 @@ impl TagCommand {
} }
4 => { 4 => {
let input: String = Input::new() let input: String = Input::new()
.with_prompt("Enter version (e.g., 1.2.3)") .with_prompt(messages.enter_version())
.interact_text()?; .interact_text()?;
let version = Version::parse(&input)?; let version = Version::parse(&input)?;
return Ok(format!("{}{}", prefix, version)); return Ok(format!("{}{}", prefix, version));
} }
5 => { 5 => {
let input: String = Input::new() let input: String = Input::new()
.with_prompt("Enter tag name") .with_prompt(messages.enter_tag_name())
.interact_text()?; .interact_text()?;
return Ok(input); return Ok(input);
} }
@@ -250,9 +263,10 @@ impl TagCommand {
} }
} }
async fn generate_tag_message(&self, repo: &GitRepo, version: &str) -> Result<String> { async fn generate_tag_message(&self, repo: &GitRepo, version: &str, messages: &Messages) -> Result<String> {
let manager = ConfigManager::new()?; let manager = ConfigManager::new()?;
let config = manager.config(); let config = manager.config();
let language = manager.get_language().unwrap_or(Language::English);
// Get commits since last tag // Get commits since last tag
let tags = repo.get_tags()?; let tags = repo.get_tags()?;
@@ -266,17 +280,17 @@ impl TagCommand {
return Ok(format!("Release {}", version)); return Ok(format!("Release {}", version));
} }
println!("{} AI is generating tag message from {} commits...", "🤖", commits.len()); println!("{}", messages.ai_generating_tag(commits.len()));
let generator = ContentGenerator::new(&config.llm).await?; let generator = ContentGenerator::new(&config.llm).await?;
generator.generate_tag_message(version, &commits).await generator.generate_tag_message(version, &commits, language).await
} }
fn input_message_interactive(&self, version: &str) -> Result<String> { fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
let default_msg = format!("Release {}", version); let default_msg = format!("Release {}", version);
let use_editor = Confirm::new() let use_editor = Confirm::new()
.with_prompt("Open editor for tag message?") .with_prompt(messages.open_editor())
.default(false) .default(false)
.interact()?; .interact()?;
@@ -284,7 +298,7 @@ impl TagCommand {
crate::utils::editor::edit_content(&default_msg) crate::utils::editor::edit_content(&default_msg)
} else { } else {
Ok(Input::new() Ok(Input::new()
.with_prompt("Tag message") .with_prompt(messages.tag_message())
.default(default_msg) .default(default_msg)
.interact_text()?) .interact_text()?)
} }

View File

@@ -1,4 +1,4 @@
use super::{AppConfig, GitProfile, TokenConfig, TokenType}; use super::{AppConfig, GitProfile, TokenConfig};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -391,6 +391,46 @@ impl ConfigManager {
self.modified = true; self.modified = true;
} }
// Language configuration
/// Get output language
pub fn output_language(&self) -> &str {
&self.config.language.output_language
}
/// Set output language
pub fn set_output_language(&mut self, language: String) {
self.config.language.output_language = language;
self.modified = true;
}
/// Get language enum from config
pub fn get_language(&self) -> Option<super::Language> {
super::Language::from_str(&self.config.language.output_language)
}
/// Check if commit types should be kept in English
pub fn keep_types_english(&self) -> bool {
self.config.language.keep_types_english
}
/// Set keep types English flag
pub fn set_keep_types_english(&mut self, keep: bool) {
self.config.language.keep_types_english = keep;
self.modified = true;
}
/// Check if changelog types should be kept in English
pub fn keep_changelog_types_english(&self) -> bool {
self.config.language.keep_changelog_types_english
}
/// Set keep changelog types English flag
pub fn set_keep_changelog_types_english(&mut self, keep: bool) {
self.config.language.keep_changelog_types_english = keep;
self.modified = true;
}
/// Export configuration to TOML string /// Export configuration to TOML string
pub fn export(&self) -> Result<String> { pub fn export(&self) -> Result<String> {
toml::to_string_pretty(&self.config) toml::to_string_pretty(&self.config)

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
@@ -54,6 +53,10 @@ pub struct AppConfig {
/// Theme settings /// Theme settings
#[serde(default)] #[serde(default)]
pub theme: ThemeConfig, pub theme: ThemeConfig,
/// Language settings
#[serde(default)]
pub language: LanguageConfig,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -69,6 +72,7 @@ impl Default for AppConfig {
repo_profiles: HashMap::new(), repo_profiles: HashMap::new(),
encrypt_sensitive: true, encrypt_sensitive: true,
theme: ThemeConfig::default(), theme: ThemeConfig::default(),
language: LanguageConfig::default(),
} }
} }
} }
@@ -498,6 +502,83 @@ impl Default for ThemeConfig {
} }
} }
/// Language configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageConfig {
/// Output language for messages (en, zh, etc.)
#[serde(default = "default_output_language")]
pub output_language: String,
/// Keep commit types in English
#[serde(default = "default_true")]
pub keep_types_english: bool,
/// Keep changelog types in English
#[serde(default = "default_true")]
pub keep_changelog_types_english: bool,
}
impl Default for LanguageConfig {
fn default() -> Self {
Self {
output_language: default_output_language(),
keep_types_english: true,
keep_changelog_types_english: true,
}
}
}
/// Supported languages
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
English,
Chinese,
Japanese,
Korean,
Spanish,
French,
German,
}
impl Language {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"en" | "english" => Some(Language::English),
"zh" | "chinese" | "zh-cn" | "zh-tw" => Some(Language::Chinese),
"ja" | "japanese" => Some(Language::Japanese),
"ko" | "korean" => Some(Language::Korean),
"es" | "spanish" => Some(Language::Spanish),
"fr" | "french" => Some(Language::French),
"de" | "german" => Some(Language::German),
_ => None,
}
}
pub fn to_code(&self) -> &str {
match self {
Language::English => "en",
Language::Chinese => "zh",
Language::Japanese => "ja",
Language::Korean => "ko",
Language::Spanish => "es",
Language::French => "fr",
Language::German => "de",
}
}
pub fn display_name(&self) -> &str {
match self {
Language::English => "English",
Language::Chinese => "中文",
Language::Japanese => "日本語",
Language::Korean => "한국어",
Language::Spanish => "Español",
Language::French => "Français",
Language::German => "Deutsch",
}
}
}
// Default value functions // Default value functions
fn default_version() -> String { fn default_version() -> String {
"1".to_string() "1".to_string()
@@ -595,6 +676,10 @@ fn default_date_format() -> String {
"%Y-%m-%d".to_string() "%Y-%m-%d".to_string()
} }
fn default_output_language() -> String {
"en".to_string()
}
impl AppConfig { impl AppConfig {
/// Load configuration from file /// Load configuration from file
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {

View File

@@ -1,4 +1,4 @@
use crate::config::{CommitFormat, LlmConfig}; use crate::config::{CommitFormat, LlmConfig, Language};
use crate::git::{CommitInfo, GitRepo}; use crate::git::{CommitInfo, GitRepo};
use crate::llm::{GeneratedCommit, LlmClient}; use crate::llm::{GeneratedCommit, LlmClient};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -27,6 +27,7 @@ impl ContentGenerator {
&self, &self,
diff: &str, diff: &str,
format: CommitFormat, format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
// Truncate diff if too long // Truncate diff if too long
let max_diff_len = 4000; let max_diff_len = 4000;
@@ -36,7 +37,7 @@ impl ContentGenerator {
diff.to_string() diff.to_string()
}; };
self.llm_client.generate_commit_message(&truncated_diff, format).await self.llm_client.generate_commit_message(&truncated_diff, format, language).await
} }
/// Generate commit message from repository changes /// Generate commit message from repository changes
@@ -44,6 +45,7 @@ impl ContentGenerator {
&self, &self,
repo: &GitRepo, repo: &GitRepo,
format: CommitFormat, format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
let diff = repo.get_staged_diff() let diff = repo.get_staged_diff()
.context("Failed to get staged diff")?; .context("Failed to get staged diff")?;
@@ -52,7 +54,7 @@ impl ContentGenerator {
anyhow::bail!("No staged changes to generate commit from"); anyhow::bail!("No staged changes to generate commit from");
} }
self.generate_commit_message(&diff, format).await self.generate_commit_message(&diff, format, language).await
} }
/// Generate tag message /// Generate tag message
@@ -60,13 +62,14 @@ impl ContentGenerator {
&self, &self,
version: &str, version: &str,
commits: &[CommitInfo], commits: &[CommitInfo],
language: Language,
) -> Result<String> { ) -> Result<String> {
let commit_messages: Vec<String> = commits let commit_messages: Vec<String> = commits
.iter() .iter()
.map(|c| c.subject().to_string()) .map(|c| c.subject().to_string())
.collect(); .collect();
self.llm_client.generate_tag_message(version, &commit_messages).await self.llm_client.generate_tag_message(version, &commit_messages, language).await
} }
/// Generate changelog entry /// Generate changelog entry
@@ -74,6 +77,7 @@ impl ContentGenerator {
&self, &self,
version: &str, version: &str,
commits: &[CommitInfo], commits: &[CommitInfo],
language: Language,
) -> Result<String> { ) -> Result<String> {
let typed_commits: Vec<(String, String)> = commits let typed_commits: Vec<(String, String)> = commits
.iter() .iter()
@@ -83,7 +87,7 @@ impl ContentGenerator {
}) })
.collect(); .collect();
self.llm_client.generate_changelog_entry(version, &typed_commits).await self.llm_client.generate_changelog_entry(version, &typed_commits, language).await
} }
/// Generate changelog from repository /// Generate changelog from repository
@@ -92,6 +96,7 @@ impl ContentGenerator {
repo: &GitRepo, repo: &GitRepo,
version: &str, version: &str,
from_tag: Option<&str>, from_tag: Option<&str>,
language: Language,
) -> Result<String> { ) -> Result<String> {
let commits = if let Some(tag) = from_tag { let commits = if let Some(tag) = from_tag {
repo.get_commits_between(tag, "HEAD")? repo.get_commits_between(tag, "HEAD")?
@@ -99,7 +104,7 @@ impl ContentGenerator {
repo.get_commits(50)? repo.get_commits(50)?
}; };
self.generate_changelog_entry(version, &commits).await self.generate_changelog_entry(version, &commits, language).await
} }
/// Interactive commit generation with user feedback /// Interactive commit generation with user feedback
@@ -107,6 +112,7 @@ impl ContentGenerator {
&self, &self,
repo: &GitRepo, repo: &GitRepo,
format: CommitFormat, format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
use dialoguer::{Confirm, Select}; use dialoguer::{Confirm, Select};
use console::Term; use console::Term;
@@ -126,7 +132,7 @@ impl ContentGenerator {
// Generate initial commit // Generate initial commit
println!("\nGenerating commit message..."); println!("\nGenerating commit message...");
let mut generated = self.generate_commit_message(&diff, format).await?; let mut generated = self.generate_commit_message(&diff, format, language).await?;
loop { loop {
println!("\n{}", "".repeat(60)); println!("\n{}", "".repeat(60));
@@ -153,7 +159,7 @@ impl ContentGenerator {
0 => return Ok(generated), 0 => return Ok(generated),
1 => { 1 => {
println!("Regenerating..."); println!("Regenerating...");
generated = self.generate_commit_message(&diff, format).await?; generated = self.generate_commit_message(&diff, format, language).await?;
} }
2 => { 2 => {
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?; let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
@@ -190,115 +196,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

@@ -22,15 +22,41 @@ impl GitRepo {
/// Open a git repository /// Open a git repository
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> { pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref(); let path = path.as_ref();
let absolute_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
// Enhanced cross-platform path handling
let absolute_path = if let Ok(canonical) = path.canonicalize() {
canonical
} else {
// Fallback: convert to absolute path without canonicalization
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
}
};
// Try multiple git repository discovery strategies for cross-platform compatibility
let repo = Repository::discover(&absolute_path) let repo = Repository::discover(&absolute_path)
.or_else(|_| Repository::open(&absolute_path)) .or_else(|discover_err| {
// Try direct open as fallback
Repository::open(&absolute_path).map_err(|open_err| {
// Provide detailed error information for debugging
anyhow::anyhow!(
"Git repository discovery failed:\n\
Discovery error: {}\n\
Direct open error: {}\n\
Path attempted: {:?}\n\
Current directory: {:?}",
discover_err, open_err, absolute_path, std::env::current_dir()
)
})
})
.with_context(|| { .with_context(|| {
format!( format!(
"Failed to open git repository at '{:?}'. Please ensure:\n\ "Failed to open git repository at '{:?}'. Please ensure:\n\
1. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\ 1. The directory contains a valid '.git' folder\n\
2. The path is correct and contains a valid '.git' folder.", 2. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\
3. You have proper permissions to access the repository",
absolute_path, absolute_path,
absolute_path.display() absolute_path.display()
) )
@@ -128,7 +154,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()
@@ -157,28 +183,19 @@ impl GitRepo {
/// Get staged diff /// Get staged diff
pub fn get_staged_diff(&self) -> Result<String> { pub fn get_staged_diff(&self) -> Result<String> {
let head = self.repo.head().ok(); // Use git CLI to get staged diff for better compatibility
let head_tree = head.as_ref() let output = std::process::Command::new("git")
.and_then(|h| h.peel_to_tree().ok()); .args(&["diff", "--cached"])
.current_dir(&self.path)
.output()
.with_context(|| "Failed to get staged diff with git command")?;
let mut index = self.repo.index()?; if !output.status.success() {
let index_tree = index.write_tree()?; let stderr = String::from_utf8_lossy(&output.stderr);
let index_tree = self.repo.find_tree(index_tree)?; bail!("Failed to get staged diff: {}", stderr);
}
let diff = if let Some(head) = head_tree {
self.repo.diff_tree_to_index(Some(&head), Some(&index), None)?
} else {
self.repo.diff_tree_to_index(None, Some(&index), None)?
};
let mut diff_text = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
if let Ok(content) = std::str::from_utf8(line.content()) {
diff_text.push_str(content);
}
true
})?;
let diff_text = String::from_utf8_lossy(&output.stdout).to_string();
Ok(diff_text) Ok(diff_text)
} }
@@ -265,28 +282,21 @@ impl GitRepo {
/// Stage all changes including subdirectories /// Stage all changes including subdirectories
pub fn stage_all(&self) -> Result<()> { pub fn stage_all(&self) -> Result<()> {
let mut index = self.repo.index()?; // Use git command for reliable staging (handles all edge cases)
let output = std::process::Command::new("git")
.args(&["add", "-A"])
.current_dir(&self.path)
.output()
.with_context(|| "Failed to stage changes with git command")?;
fn add_directory_recursive(index: &mut git2::Index, base_dir: &Path, current_dir: &Path) -> Result<()> { if !output.status.success() {
for entry in std::fs::read_dir(current_dir) let stderr = String::from_utf8_lossy(&output.stderr);
.with_context(|| format!("Failed to read directory: {:?}", current_dir))? bail!("Failed to stage changes: {}", stderr);
{
let entry = entry?;
let path = entry.path();
if path.is_file() {
if let Ok(rel_path) = path.strip_prefix(base_dir) {
let _ = index.add_path(rel_path);
}
} else if path.is_dir() {
add_directory_recursive(index, base_dir, &path)?;
}
}
Ok(())
} }
add_directory_recursive(&mut index, &self.path, &self.path)?; // Force refresh the git2 index to pick up changes from git CLI
index.write()?; let _ = self.repo.index()?.write();
Ok(()) Ok(())
} }
@@ -572,33 +582,50 @@ impl GitRepo {
/// Get repository status summary /// Get repository status summary
pub fn status_summary(&self) -> Result<StatusSummary> { pub fn status_summary(&self) -> Result<StatusSummary> {
let statuses = self.repo.statuses(Some(StatusOptions::new().include_untracked(true)))?; // Use git CLI for more reliable status detection
let output = std::process::Command::new("git")
.args(&["status", "--porcelain"])
.current_dir(&self.path)
.output()
.with_context(|| "Failed to get status with git command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to get status: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut staged = 0; let mut staged = 0;
let mut unstaged = 0; let mut unstaged = 0;
let mut untracked = 0; let mut untracked = 0;
let mut conflicted = 0; let mut conflicted = 0;
for entry in statuses.iter() { for line in stdout.lines() {
let status = entry.status(); if line.len() >= 2 {
let index_status = line.chars().next().unwrap();
let worktree_status = line.chars().nth(1).unwrap();
if status.is_index_new() || status.is_index_modified() || // Staged changes (first column not space)
status.is_index_deleted() || status.is_index_renamed() || if index_status != ' ' && index_status != '?' {
status.is_index_typechange() { staged += 1;
staged += 1; }
}
if status.is_wt_modified() || status.is_wt_deleted() || // Unstaged changes (second column not space)
status.is_wt_renamed() || status.is_wt_typechange() { if worktree_status != ' ' && worktree_status != '?' {
unstaged += 1; unstaged += 1;
} }
if status.is_wt_new() { // Untracked files (both columns are ?)
untracked += 1; if index_status == '?' && worktree_status == '?' {
} untracked += 1;
}
if status.is_conflicted() { // Conflicted files (both columns are U or DD, AA, etc.)
conflicted += 1; if (index_status == 'U' || worktree_status == 'U') ||
(index_status == 'A' && worktree_status == 'A') ||
(index_status == 'D' && worktree_status == 'D') {
conflicted += 1;
}
} }
} }
@@ -688,19 +715,37 @@ impl StatusSummary {
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> { pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
let start_path = start_path.as_ref(); let start_path = start_path.as_ref();
// Try the starting path first
if let Ok(repo) = GitRepo::open(start_path) { if let Ok(repo) = GitRepo::open(start_path) {
return Ok(repo); return Ok(repo);
} }
// Walk up the directory tree to find a git repository
let mut current = start_path; let mut current = start_path;
let mut attempted_paths = vec![current.to_string_lossy().to_string()];
while let Some(parent) = current.parent() { while let Some(parent) = current.parent() {
attempted_paths.push(parent.to_string_lossy().to_string());
if let Ok(repo) = GitRepo::open(parent) { if let Ok(repo) = GitRepo::open(parent) {
return Ok(repo); return Ok(repo);
} }
current = parent; current = parent;
} }
bail!("No git repository found starting from {:?}", start_path) // Provide detailed error information for debugging
bail!(
"No git repository found starting from {:?}.\n\
Paths attempted:\n {}\n\
Current directory: {:?}\n\
Please ensure:\n\
1. You are in a git repository or its subdirectory\n\
2. The repository has a valid .git folder\n\
3. You have proper permissions to access the repository",
start_path,
attempted_paths.join("\n "),
std::env::current_dir()
)
} }
/// Check if path is inside a git repository /// Check if path is inside a git repository

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) {

1151
src/i18n/messages.rs Normal file

File diff suppressed because it is too large Load Diff

7
src/i18n/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod messages;
pub mod translator;
pub use messages::Messages;
pub use translator::Translator;
pub use translator::translate_commit_type;
pub use translator::translate_changelog_category;

233
src/i18n/translator.rs Normal file
View File

@@ -0,0 +1,233 @@
use crate::config::Language;
pub struct Translator {
language: Language,
keep_types_english: bool,
keep_changelog_types_english: bool,
}
impl Translator {
pub fn new(language: Language, keep_types_english: bool, keep_changelog_types_english: bool) -> Self {
Self {
language,
keep_types_english,
keep_changelog_types_english,
}
}
pub fn translate_commit_type(&self, commit_type: &str) -> String {
if self.keep_types_english {
return commit_type.to_string();
}
match self.language {
Language::English => commit_type.to_string(),
Language::Chinese => self.translate_commit_type_zh(commit_type),
Language::Japanese => self.translate_commit_type_ja(commit_type),
Language::Korean => self.translate_commit_type_ko(commit_type),
Language::Spanish => self.translate_commit_type_es(commit_type),
Language::French => self.translate_commit_type_fr(commit_type),
Language::German => self.translate_commit_type_de(commit_type),
}
}
pub fn translate_changelog_category(&self, category: &str) -> String {
if self.keep_changelog_types_english {
return category.to_string();
}
match self.language {
Language::English => category.to_string(),
Language::Chinese => self.translate_changelog_category_zh(category),
Language::Japanese => self.translate_changelog_category_ja(category),
Language::Korean => self.translate_changelog_category_ko(category),
Language::Spanish => self.translate_changelog_category_es(category),
Language::French => self.translate_changelog_category_fr(category),
Language::German => self.translate_changelog_category_de(category),
}
}
fn translate_commit_type_zh(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "新功能".to_string(),
"fix" => "修复".to_string(),
"docs" => "文档".to_string(),
"style" => "样式".to_string(),
"refactor" => "重构".to_string(),
"perf" => "性能".to_string(),
"test" => "测试".to_string(),
"build" => "构建".to_string(),
"ci" => "CI".to_string(),
"chore" => "杂项".to_string(),
"revert" => "回滚".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_ja(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "機能".to_string(),
"fix" => "修正".to_string(),
"docs" => "ドキュメント".to_string(),
"style" => "スタイル".to_string(),
"refactor" => "リファクタリング".to_string(),
"perf" => "パフォーマンス".to_string(),
"test" => "テスト".to_string(),
"build" => "ビルド".to_string(),
"ci" => "CI".to_string(),
"chore" => "雑務".to_string(),
"revert" => "取り消し".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_ko(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "기능".to_string(),
"fix" => "버그 수정".to_string(),
"docs" => "문서".to_string(),
"style" => "스타일".to_string(),
"refactor" => "리팩토링".to_string(),
"perf" => "성능".to_string(),
"test" => "테스트".to_string(),
"build" => "빌드".to_string(),
"ci" => "CI".to_string(),
"chore" => "기타".to_string(),
"revert" => "되돌리기".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_es(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "nueva función".to_string(),
"fix" => "corrección".to_string(),
"docs" => "documentación".to_string(),
"style" => "estilo".to_string(),
"refactor" => "refactorización".to_string(),
"perf" => "rendimiento".to_string(),
"test" => "pruebas".to_string(),
"build" => "construcción".to_string(),
"ci" => "CI".to_string(),
"chore" => "tareas".to_string(),
"revert" => "revertir".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_fr(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "nouvelle fonctionnalité".to_string(),
"fix" => "correction".to_string(),
"docs" => "documentation".to_string(),
"style" => "style".to_string(),
"refactor" => "refactorisation".to_string(),
"perf" => "performance".to_string(),
"test" => "tests".to_string(),
"build" => "construction".to_string(),
"ci" => "CI".to_string(),
"chore" => "tâches".to_string(),
"revert" => "rétablir".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_commit_type_de(&self, commit_type: &str) -> String {
match commit_type {
"feat" => "Neue Funktion".to_string(),
"fix" => "Korrektur".to_string(),
"docs" => "Dokumentation".to_string(),
"style" => "Stil".to_string(),
"refactor" => "Refactoring".to_string(),
"perf" => "Leistung".to_string(),
"test" => "Tests".to_string(),
"build" => "Build".to_string(),
"ci" => "CI".to_string(),
"chore" => "Wartung".to_string(),
"revert" => "Zurücksetzen".to_string(),
_ => commit_type.to_string(),
}
}
fn translate_changelog_category_zh(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "新增".to_string(),
"changed" => "更改".to_string(),
"deprecated" => "弃用".to_string(),
"removed" => "移除".to_string(),
"fixed" => "修复".to_string(),
"security" => "安全".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_ja(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "追加".to_string(),
"changed" => "変更".to_string(),
"deprecated" => "非推奨".to_string(),
"removed" => "削除".to_string(),
"fixed" => "修正".to_string(),
"security" => "セキュリティ".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_ko(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "추가됨".to_string(),
"changed" => "변경됨".to_string(),
"deprecated" => "사용 중단".to_string(),
"removed" => "제거됨".to_string(),
"fixed" => "수정됨".to_string(),
"security" => "보안".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_es(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "Agregado".to_string(),
"changed" => "Cambiado".to_string(),
"deprecated" => "Obsoleto".to_string(),
"removed" => "Eliminado".to_string(),
"fixed" => "Corregido".to_string(),
"security" => "Seguridad".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_fr(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "Ajouté".to_string(),
"changed" => "Modifié".to_string(),
"deprecated" => "Obsolète".to_string(),
"removed" => "Supprimé".to_string(),
"fixed" => "Corrigé".to_string(),
"security" => "Sécurité".to_string(),
_ => category.to_string(),
}
}
fn translate_changelog_category_de(&self, category: &str) -> String {
match category.to_lowercase().as_str() {
"added" => "Hinzugefügt".to_string(),
"changed" => "Geändert".to_string(),
"deprecated" => "Veraltet".to_string(),
"removed" => "Entfernt".to_string(),
"fixed" => "Behoben".to_string(),
"security" => "Sicherheit".to_string(),
_ => category.to_string(),
}
}
}
pub fn translate_commit_type(commit_type: &str, language: Language, keep_english: bool) -> String {
let translator = Translator::new(language, keep_english, true);
translator.translate_commit_type(commit_type)
}
pub fn translate_changelog_category(category: &str, language: Language, keep_english: bool) -> String {
let translator = Translator::new(language, true, keep_english);
translator.translate_changelog_category(category)
}

View File

@@ -70,6 +70,12 @@ impl AnthropicClient {
Ok(self) Ok(self)
} }
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
// Anthropic doesn't have a models API endpoint, return predefined list
Ok(ANTHROPIC_MODELS.iter().map(|&m| m.to_string()).collect())
}
/// Validate API key /// Validate API key
pub async fn validate_key(&self) -> Result<bool> { pub async fn validate_key(&self) -> Result<bool> {
let url = "https://api.anthropic.com/v1/messages"; let url = "https://api.anthropic.com/v1/messages";

View File

@@ -82,8 +82,8 @@ impl DeepSeekClient {
Ok(self) Ok(self)
} }
/// Validate API key /// List available models
pub async fn validate_key(&self) -> Result<bool> { pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url); let url = format!("{}/models", self.base_url);
let response = self.client let response = self.client
@@ -91,16 +91,44 @@ impl DeepSeekClient {
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
.send() .send()
.await .await
.context("Failed to validate DeepSeek API key")?; .context("Failed to list DeepSeek models")?;
if response.status().is_success() { if !response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
bail!("DeepSeek API error: {} - {}", status, text) bail!("DeepSeek API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse DeepSeek response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
} }
} }
} }

View File

@@ -82,8 +82,8 @@ impl KimiClient {
Ok(self) Ok(self)
} }
/// Validate API key /// List available models
pub async fn validate_key(&self) -> Result<bool> { pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url); let url = format!("{}/models", self.base_url);
let response = self.client let response = self.client
@@ -91,16 +91,44 @@ impl KimiClient {
.header("Authorization", format!("Bearer {}", self.api_key)) .header("Authorization", format!("Bearer {}", self.api_key))
.send() .send()
.await .await
.context("Failed to validate Kimi API key")?; .context("Failed to list Kimi models")?;
if response.status().is_success() { if !response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
bail!("Kimi API error: {} - {}", status, text) bail!("Kimi API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse Kimi response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
} }
} }
} }

View File

@@ -2,6 +2,7 @@ use anyhow::{bail, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
use crate::config::Language;
pub mod ollama; pub mod ollama;
pub mod openai; pub mod openai;
@@ -120,17 +121,22 @@ impl LlmClient {
&self, &self,
diff: &str, diff: &str,
format: crate::config::CommitFormat, format: crate::config::CommitFormat,
language: Language,
) -> Result<GeneratedCommit> { ) -> Result<GeneratedCommit> {
let system_prompt = match format { let system_prompt = get_commit_system_prompt(format, language);
crate::config::CommitFormat::Conventional => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT // Add language instruction to the prompt
} let language_instruction = match language {
crate::config::CommitFormat::Commitlint => { Language::Chinese => "\n\n请用中文生成提交消息。",
COMMITLINT_SYSTEM_PROMPT Language::Japanese => "\n\n日本語でコミットメッセージを生成してください。",
} Language::Korean => "\n\n한국어로 커밋 메시지를 생성하세요.",
Language::Spanish => "\n\nPor favor, genera el mensaje de commit en español.",
Language::French => "\n\nVeuillez générer le message de commit en français.",
Language::German => "\n\nBitte generieren Sie die Commit-Nachricht auf Deutsch.",
Language::English => "",
}; };
let prompt = format!("{}", diff); let prompt = format!("{}{}", diff, language_instruction);
let response = self.provider.generate_with_system(system_prompt, &prompt).await?; let response = self.provider.generate_with_system(system_prompt, &prompt).await?;
self.parse_commit_response(&response, format) self.parse_commit_response(&response, format)
@@ -141,10 +147,23 @@ impl LlmClient {
&self, &self,
version: &str, version: &str,
commits: &[String], commits: &[String],
language: Language,
) -> Result<String> { ) -> Result<String> {
let system_prompt = TAG_MESSAGE_SYSTEM_PROMPT; let system_prompt = get_tag_system_prompt(language);
let commits_text = commits.join("\n"); let commits_text = commits.join("\n");
let prompt = format!("Version: {}\n\nCommits:\n{}", version, commits_text);
// Add language instruction to the prompt
let language_instruction = match language {
Language::Chinese => "\n\n请用中文生成标签消息。",
Language::Japanese => "\n\n日本語でタグメッセージを生成してください。",
Language::Korean => "\n\n한국어로 태그 메시지를 생성하세요.",
Language::Spanish => "\n\nPor favor, genera el mensaje de etiqueta en español.",
Language::French => "\n\nVeuillez générer le message de balise en français.",
Language::German => "\n\nBitte generieren Sie die Tag-Nachricht auf Deutsch.",
Language::English => "",
};
let prompt = format!("Version: {}\n\nCommits:\n{}{}", version, commits_text, language_instruction);
self.provider.generate_with_system(system_prompt, &prompt).await self.provider.generate_with_system(system_prompt, &prompt).await
} }
@@ -154,8 +173,9 @@ impl LlmClient {
&self, &self,
version: &str, version: &str,
commits: &[(String, String)], // (type, message) commits: &[(String, String)], // (type, message)
language: Language,
) -> Result<String> { ) -> Result<String> {
let system_prompt = CHANGELOG_SYSTEM_PROMPT; let system_prompt = get_changelog_system_prompt(language);
let commits_text = commits let commits_text = commits
.iter() .iter()
@@ -163,7 +183,18 @@ impl LlmClient {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let prompt = format!("Version: {}\n\nCommits:\n{}", version, commits_text); // Add language instruction to the prompt
let language_instruction = match language {
Language::Chinese => "\n\n请用中文生成变更日志。",
Language::Japanese => "\n\n日本語で変更ログを生成してください。",
Language::Korean => "\n\n한국어로 변경 로그를 생성하세요.",
Language::Spanish => "\n\nPor favor, genera el registro de cambios en español.",
Language::French => "\n\nVeuillez générer le journal des modifications en français.",
Language::German => "\n\nBitte generieren Sie das Changelog auf Deutsch.",
Language::English => "",
};
let prompt = format!("Version: {}\n\nCommits:\n{}{}", version, commits_text, language_instruction);
self.provider.generate_with_system(system_prompt, &prompt).await self.provider.generate_with_system(system_prompt, &prompt).await
} }
@@ -362,6 +393,58 @@ impl GeneratedCommit {
} }
} }
/// HTTP client helper
pub(crate) fn create_http_client(timeout: Duration) -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(timeout)
.build()
.context("Failed to create HTTP client")
}
/// Get commit system prompt based on format and language
fn get_commit_system_prompt(format: crate::config::CommitFormat, language: Language) -> &'static str {
match (format, language) {
(crate::config::CommitFormat::Conventional, Language::Chinese) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ZH,
(crate::config::CommitFormat::Conventional, Language::Japanese) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_JA,
(crate::config::CommitFormat::Conventional, Language::Korean) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_KO,
(crate::config::CommitFormat::Conventional, Language::Spanish) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ES,
(crate::config::CommitFormat::Conventional, Language::French) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_FR,
(crate::config::CommitFormat::Conventional, Language::German) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT_DE,
(crate::config::CommitFormat::Conventional, _) => CONVENTIONAL_COMMIT_SYSTEM_PROMPT,
(crate::config::CommitFormat::Commitlint, Language::Chinese) => COMMITLINT_SYSTEM_PROMPT_ZH,
(crate::config::CommitFormat::Commitlint, Language::Japanese) => COMMITLINT_SYSTEM_PROMPT_JA,
(crate::config::CommitFormat::Commitlint, Language::Korean) => COMMITLINT_SYSTEM_PROMPT_KO,
(crate::config::CommitFormat::Commitlint, Language::Spanish) => COMMITLINT_SYSTEM_PROMPT_ES,
(crate::config::CommitFormat::Commitlint, Language::French) => COMMITLINT_SYSTEM_PROMPT_FR,
(crate::config::CommitFormat::Commitlint, Language::German) => COMMITLINT_SYSTEM_PROMPT_DE,
(crate::config::CommitFormat::Commitlint, _) => COMMITLINT_SYSTEM_PROMPT,
}
}
fn get_tag_system_prompt(language: Language) -> &'static str {
match language {
Language::Chinese => TAG_MESSAGE_SYSTEM_PROMPT_ZH,
Language::Japanese => TAG_MESSAGE_SYSTEM_PROMPT_JA,
Language::Korean => TAG_MESSAGE_SYSTEM_PROMPT_KO,
Language::Spanish => TAG_MESSAGE_SYSTEM_PROMPT_ES,
Language::French => TAG_MESSAGE_SYSTEM_PROMPT_FR,
Language::German => TAG_MESSAGE_SYSTEM_PROMPT_DE,
_ => TAG_MESSAGE_SYSTEM_PROMPT,
}
}
fn get_changelog_system_prompt(language: Language) -> &'static str {
match language {
Language::Chinese => CHANGELOG_SYSTEM_PROMPT_ZH,
Language::Japanese => CHANGELOG_SYSTEM_PROMPT_JA,
Language::Korean => CHANGELOG_SYSTEM_PROMPT_KO,
Language::Spanish => CHANGELOG_SYSTEM_PROMPT_ES,
Language::French => CHANGELOG_SYSTEM_PROMPT_FR,
Language::German => CHANGELOG_SYSTEM_PROMPT_DE,
_ => CHANGELOG_SYSTEM_PROMPT,
}
}
// System prompts for LLM // System prompts for LLM
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates conventional commit messages. const CONVENTIONAL_COMMIT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates conventional commit messages.
@@ -394,6 +477,186 @@ Rules:
Output ONLY the commit message, nothing else. Output ONLY the commit message, nothing else.
"#; "#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成符合 Conventional Commits 规范的提交消息的助手。
分析提供的 git diff并生成符合 Conventional Commits 规范的提交消息。
格式: <type>[可选作用域]: <描述>
类型:
- feat: 新功能
- fix: 修复错误
- docs: 仅文档更改
- style: 不影响代码含义的更改(格式化、分号等)
- refactor: 既不修复错误也不添加功能的代码更改
- perf: 提高性能的代码更改
- test: 添加或更正测试
- build: 更改构建系统或依赖项
- ci: 更改 CI 配置
- chore: 其他不修改 src 或测试文件的更改
- revert: 撤销之前的提交
规则:
1. 类型和小写使用小写
2. 描述保持在 100 个字符以内
3. 使用祈使语气("添加"而不是"已添加"
4. 不要大写首字母
5. 结尾不要句号
6. 如果更改特定于模块/组件,请包含作用域
仅输出提交消息,不要输出其他内容。
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_JA: &str = r#"あなたはConventional Commits仕様に従ったコミットメッセージを生成するアシスタントです。
提供されたgit diffを分析し、Conventional Commits仕様に従ったコミットメッセージを生成してください。
形式: <type>[オプションのスコープ]: <説明>
タイプ:
- feat: 新機能
- fix: バグ修正
- docs: ドキュメントのみの変更
- style: コードの意味に影響しない変更(フォーマット、セミコロンなど)
- refactor: バグ修正や機能追加を伴わないコード変更
- perf: パフォーマンスを向上させるコード変更
- test: テストの追加または修正
- build: ビルドシステムまたは依存関係の変更
- ci: CI設定の変更
- chore: srcやテストファイルを変更しないその他の変更
- revert: 以前のコミットを取り消す
ルール:
1. タイプとスコープは小文字を使用
2. 説明は100文字以内にする
3. 命令形を使用する("追加"ではなく"追加する"
4. 先頭を大文字にしない
5. 最後にピリオドを付けない
6. 変更がモジュール/コンポーネントに固有の場合はスコープを含める
コミットメッセージのみを出力し、それ以外は出力しないでください。
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_KO: &str = r#"당신은 Conventional Commits 사양에 따른 커밋 메시지를 생성하는 도우미입니다.
제공된 git diff를 분석하고 Conventional Commits 사양에 따른 커밋 메시지를 생성하세요.
형식: <type>[선택적 범위]: <설명>
유형:
- feat: 새 기능
- fix: 버그 수정
- docs: 문서 변경만
- style: 코드 의미에 영향을 주지 않는 변경(서식, 세미콜론 등)
- refactor: 버그를 수정하거나 기능을 추가하지 않는 코드 변경
- perf: 성능을 향상시키는 코드 변경
- test: 테스트 추가 또는 수정
- build: 빌드 시스템 또는 종속성 변경
- ci: CI 구성 변경
- chore: src 또는 테스트 파일을 수정하지 않는 기타 변경
- revert: 이전 커밋 되돌리기
규칙:
1. 유형과 범위는 소문자 사용
2. 설명은 100자 이내로 유지
3. 명령형 사용("추가"가 아닌 "추가하다")
4. 첫 글자 대문자화하지 않음
5. 끝에 마침표 사용하지 않음
6. 변경 사항이 모듈/구성 요소에 특정한 경우 범위 포함
커밋 메시지만 출력하고 다른 내용은 출력하지 마세요.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de commit siguiendo la especificación Conventional Commits.
Analiza el diff de git proporcionado y genera un mensaje de commit siguiendo la especificación Conventional Commits.
Formato: <tipo>[alcance opcional]: <descripción>
Tipos:
- feat: Una nueva característica
- fix: Una corrección de error
- docs: Solo cambios en documentación
- style: Cambios que no afectan el significado del código (formato, punto y coma, etc.)
- refactor: Cambio de código que no corrige un error ni agrega una característica
- perf: Cambio de código que mejora el rendimiento
- test: Agregar o corregir pruebas
- build: Cambios en el sistema de construcción o dependencias
- ci: Cambios en la configuración de CI
- chore: Otros cambios que no modifican archivos src o de prueba
- revert: Revierte un commit anterior
Reglas:
1. Usa minúsculas para tipo y alcance
2. Mantén la descripción bajo 100 caracteres
3. Usa modo imperativo ("agregar" no "agregado")
4. No capitalices la primera letra
5. Sin punto al final
6. Incluye alcance si el cambio es específico de un módulo/componente
Genera SOLO el mensaje de commit, nada más.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages de commit suivant la spécification Conventional Commits.
Analysez le diff git fourni et générez un message de commit suivant la spécification Conventional Commits.
Format: <type>[portée optionnelle]: <description>
Types:
- feat: Une nouvelle fonctionnalité
- fix: Une correction de bug
- docs: Changements de documentation uniquement
- style: Changements qui n'affectent pas la signification du code (formatage, points-virgules, etc.)
- refactor: Changement de code qui ne corrige pas un bug ni n'ajoute une fonctionnalité
- perf: Changement de code qui améliore les performances
- test: Ajout ou correction de tests
- build: Changements du système de build ou des dépendances
- ci: Changements de la configuration CI
- chore: Autres changements qui ne modifient pas les fichiers src ou de test
- revert: Révertit un commit précédent
Règles:
1. Utilisez des minuscules pour le type et la portée
2. Gardez la description sous 100 caractères
3. Utilisez le mode impératif ("ajouter" non "ajouté")
4. Ne capitalisez pas la première lettre
5. Pas de point à la fin
6. Incluez la portée si le changement est spécifique à un module/composant
Générez SEULEMENT le message de commit, rien d'autre.
"#;
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Commit-Nachrichten gemäß der Conventional Commits-Spezifikation generiert.
Analysieren Sie den bereitgestellten git diff und generieren Sie eine Commit-Nachricht gemäß der Conventional Commits-Spezifikation.
Format: <typ>[optionaler Bereich]: <beschreibung>
Typen:
- feat: Eine neue Funktion
- fix: Ein Bugfix
- docs: Nur Dokumentationsänderungen
- style: Änderungen, die die Code-Bedeutung nicht beeinflussen (Formatierung, Semikolons usw.)
- refactor: Code-Änderung, die weder einen Bug behebt noch eine Funktion hinzufügt
- perf: Code-Änderung, die die Leistung verbessert
- test: Hinzufügen oder Korrigieren von Tests
- build: Änderungen am Build-System oder Abhängigkeiten
- ci: Änderungen an der CI-Konfiguration
- chore: Andere Änderungen, die src- oder Testdateien nicht ändern
- revert: Setzt einen vorherigen Commit zurück
Regeln:
1. Verwenden Sie Kleinbuchstaben für Typ und Bereich
2. Halten Sie die Beschreibung unter 100 Zeichen
3. Verwenden Sie den Imperativ ("hinzufügen" nicht "hinzugefügt")
4. Großschreiben Sie den ersten Buchstaben nicht
5. Kein Punkt am Ende
6. Fügen Sie einen Bereich ein, wenn die Änderung spezifisch für ein Modul/Komponente ist
Geben Sie NUR die Commit-Nachricht aus, nichts anderes.
"#;
const COMMITLINT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates commit messages following @commitlint/config-conventional. const COMMITLINT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates commit messages following @commitlint/config-conventional.
Analyze the git diff and generate a commit message. Analyze the git diff and generate a commit message.
@@ -412,6 +675,114 @@ Rules:
Output ONLY the commit message, nothing else. Output ONLY the commit message, nothing else.
"#; "#;
const COMMITLINT_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成符合 @commitlint/config-conventional 规范的提交消息的助手。
分析 git diff 并生成提交消息。
格式: <type>[可选作用域]: <主题>
类型: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
规则:
1. 主题不应以大写字母开头
2. 主题不应以句号结尾
3. 主题应为 4-100 个字符
4. 使用祈使语气
5. 简洁但描述性强
仅输出提交消息,不要输出其他内容。
"#;
const COMMITLINT_SYSTEM_PROMPT_JA: &str = r#"あなたは@commitlint/config-conventionalに従ったコミットメッセージを生成するアシスタントです。
git diffを分析し、コミットメッセージを生成してください。
形式: <type>[オプションのスコープ]: <件名>
タイプ: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
ルール:
1. 件名は大文字で始めないでください
2. 件名はピリオドで終わらないでください
3. 件名は4-100文字である必要があります
4. 命令形を使用してください
5. 簡潔ですが説明的であること
コミットメッセージのみを出力し、それ以外は出力しないでください。
"#;
const COMMITLINT_SYSTEM_PROMPT_KO: &str = r#"당신은 @commitlint/config-conventional에 따른 커밋 메시지를 생성하는 도우미입니다.
git diff를 분석하고 커밋 메시지를 생성하세요.
형식: <type>[선택적 범위]: <제목>
유형: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
규칙:
1. 제목은 대문자로 시작하지 않아야 합니다
2. 제목은 마침표로 끝나지 않아야 합니다
3. 제목은 4-100자여야 합니다
4. 명령형을 사용하세요
5. 간결하지만 설명적이어야 합니다
커밋 메시지만 출력하고 다른 내용은 출력하지 마세요.
"#;
const COMMITLINT_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de commit siguiendo @commitlint/config-conventional.
Analiza el diff de git y genera un mensaje de commit.
Formato: <tipo>[alcance opcional]: <asunto>
Tipos: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Reglas:
1. El asunto no debe comenzar con mayúscula
2. El asunto no debe terminar con punto
3. El asunto debe tener 4-100 caracteres
4. Usa modo imperativo
5. Sé conciso pero descriptivo
Genera SOLO el mensaje de commit, nada más.
"#;
const COMMITLINT_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages de commit suivant @commitlint/config-conventional.
Analysez le diff git et générez un message de commit.
Format: <type>[portée optionnelle]: <sujet>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Règles:
1. Le sujet ne doit pas commencer par une majuscule
2. Le sujet ne doit pas se terminer par un point
3. Le sujet doit avoir 4-100 caractères
4. Utilisez le mode impératif
5. Soyez concis mais descriptif
Générez SEULEMENT le message de commit, rien d'autre.
"#;
const COMMITLINT_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Commit-Nachrichten gemäß @commitlint/config-conventional generiert.
Analysieren Sie den git diff und generieren Sie eine Commit-Nachricht.
Format: <typ>[optionaler Bereich]: <betreff>
Typen: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Regeln:
1. Der Betreff sollte nicht mit einem Großbuchstaben beginnen
2. Der Betreff sollte nicht mit einem Punkt enden
3. Der Betreff sollte 4-100 Zeichen haben
4. Verwenden Sie den Imperativ
5. Seien Sie prägnant aber beschreibend
Geben Sie NUR die Commit-Nachricht aus, nichts anderes.
"#;
const TAG_MESSAGE_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates git tag annotation messages. const TAG_MESSAGE_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates git tag annotation messages.
Given a version number and a list of commits, generate a concise but informative tag message. Given a version number and a list of commits, generate a concise but informative tag message.
@@ -432,6 +803,126 @@ Changes:
... ...
"#; "#;
const TAG_MESSAGE_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成 git 标签注释消息的助手。
给定版本号和提交列表,生成简洁但信息丰富的标签消息。
消息应该:
1. 以发布的简要摘要开始
2. 按类型分组更改(功能、修复等)
3. 适合 git 标注标签
格式:
<version> 发布
更改摘要...
更改:
- 功能:描述
- 修复:描述
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_JA: &str = r#"あなたはgitタグ注釈メッセージを生成するアシスタントです。
バージョン番号とコミットのリストを考慮して、簡潔ですが情報豊富なタグメッセージを生成してください。
メッセージは以下のようであるべきです:
1. リリースの簡単な要約から始める
2. タイプ別に変更をグループ化する(機能、修正など)
3. git注釈タグに適している
形式:
<version> リリース
変更の概要...
変更:
- 機能:説明
- 修正:説明
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_KO: &str = r#"당신은 git 태그 주석 메시지를 생성하는 도우미입니다.
버전 번호와 커밋 목록을 고려하여 간결하지만 정보가 풍부한 태그 메시지를 생성하세요.
메시지는 다음과 같아야 합니다:
1. 릴리스의 간단한 요약으로 시작
2. 유형별로 변경 사항 그룹화(기능, 수정 등)
3. git 주석 태그에 적합
형식:
<version> 릴리스
변경 사항 요약...
변경 사항:
- 기능: 설명
- 수정: 설명
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera mensajes de anotación de etiquetas git.
Dado un número de versión y una lista de commits, genera un mensaje de etiqueta conciso pero informativo.
El mensaje debe:
1. Comenzar con un resumen breve de la versión
2. Agrupar cambios por tipo (características, correcciones, etc.)
3. Ser adecuado para una etiqueta git anotada
Formato:
<version> Versión
Resumen de cambios...
Cambios:
- Característica: descripción
- Corrección: descripción
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des messages d'annotation de balises git.
Étant donné un numéro de version et une liste de commits, générez un message de balise concis mais informatif.
Le message doit :
1. Commencer par un bref résumé de la version
2. Grouper les changements par type (fonctionnalités, corrections, etc.)
3. Être adapté à une balise git annotée
Format :
<version> Version
Résumé des changements...
Changements :
- Fonctionnalité : description
- Correction : description
...
"#;
const TAG_MESSAGE_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der git-Tag-Anmerkungsnachrichten generiert.
Gegeben eine Versionsnummer und eine Liste von Commits, generieren Sie eine prägnante aber informative Tag-Nachricht.
Die Nachricht sollte:
1. Mit einer kurzen Zusammenfassung der Version beginnen
2. Änderungen nach Typ gruppieren (Funktionen, Fixes, etc.)
3. Für ein git-annotiertes Tag geeignet sein
Format:
<version> Version
Zusammenfassung der Änderungen...
Änderungen:
- Funktion: Beschreibung
- Fix: Beschreibung
...
"#;
const CHANGELOG_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates changelog entries. const CHANGELOG_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates changelog entries.
Given a version and a list of commits, generate a well-formatted changelog section. Given a version and a list of commits, generate a well-formatted changelog section.
@@ -445,10 +936,80 @@ Group commits by:
Format in markdown with proper headings and bullet points. Format in markdown with proper headings and bullet points.
"#; "#;
/// HTTP client helper const CHANGELOG_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成变更日志条目的助手。
pub(crate) fn create_http_client(timeout: Duration) -> Result<reqwest::Client> {
reqwest::Client::builder() 给定版本和提交列表,生成格式良好的变更日志部分。
.timeout(timeout)
.build() 按以下方式分组提交:
.context("Failed to create HTTP client") - 功能 (feat)
} - 错误修复 (fix)
- 文档 (docs)
- 其他更改
使用适当的标题和项目符号以 markdown 格式输出。
"#;
const CHANGELOG_SYSTEM_PROMPT_JA: &str = r#"あなたは変更ログエントリを生成するアシスタントです。
バージョンとコミットのリストを考慮して、適切にフォーマットされた変更ログセクションを生成してください。
コミットを以下でグループ化してください:
- 機能 (feat)
- バグ修正 (fix)
- ドキュメント (docs)
- その他の変更
適切な見出しと箇条書きを使用してmarkdown形式でフォーマットしてください。
"#;
const CHANGELOG_SYSTEM_PROMPT_KO: &str = r#"당신은 변경 로그 항목을 생성하는 도우미입니다.
버전과 커밋 목록을 고려하여 잘 포맷된 변경 로그 섹션을 생성하세요.
다음으로 커밋을 그룹화하세요:
- 기능 (feat)
- 버그 수정 (fix)
- 문서 (docs)
- 기타 변경 사항
적절한 제목과 글머리 기호를 사용하여 markdown 형식으로 포맷하세요.
"#;
const CHANGELOG_SYSTEM_PROMPT_ES: &str = r#"Eres un asistente que genera entradas de registro de cambios.
Dada una versión y una lista de commits, genera una sección de registro de cambios bien formateada.
Agrupa los commits por:
- Características (feat)
- Correcciones de errores (fix)
- Documentación (docs)
- Otros cambios
Formatea en markdown con encabezados y viñetas apropiados.
"#;
const CHANGELOG_SYSTEM_PROMPT_FR: &str = r#"Vous êtes un assistant qui génère des entrées de journal des modifications.
Étant donné une version et une liste de commits, générez une section de journal des modifications bien formatée.
Groupez les commits par :
- Fonctionnalités (feat)
- Corrections de bugs (fix)
- Documentation (docs)
- Autres modifications
Formatez en markdown avec des en-têtes et des puces appropriés.
"#;
const CHANGELOG_SYSTEM_PROMPT_DE: &str = r#"Sie sind ein Assistent, der Changelog-Einträge generiert.
Gegeben eine Version und eine Liste von Commits, generieren Sie einen gut formatierten Changelog-Abschnitt.
Gruppieren Sie Commits nach:
- Funktionen (feat)
- Bugfixes (fix)
- Dokumentation (docs)
- Andere Änderungen
Formatieren Sie in Markdown mit geeigneten Überschriften und Aufzählungspunkten.
"#;

View File

@@ -82,8 +82,8 @@ impl OpenRouterClient {
Ok(self) Ok(self)
} }
/// Validate API key /// List available models
pub async fn validate_key(&self) -> Result<bool> { pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url); let url = format!("{}/models", self.base_url);
let response = self.client let response = self.client
@@ -93,16 +93,44 @@ impl OpenRouterClient {
.header("X-Title", "QuiCommit") .header("X-Title", "QuiCommit")
.send() .send()
.await .await
.context("Failed to validate OpenRouter API key")?; .context("Failed to list OpenRouter models")?;
if response.status().is_success() { if !response.status().is_success() {
Ok(true)
} else if response.status().as_u16() == 401 {
Ok(false)
} else {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
bail!("OpenRouter API error: {} - {}", status, text) bail!("OpenRouter API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse OpenRouter response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
} }
} }
} }

View File

@@ -6,6 +6,7 @@ mod commands;
mod config; mod config;
mod generator; mod generator;
mod git; mod git;
mod i18n;
mod llm; mod llm;
mod utils; mod utils;