Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
04410ea9e7
|
|||
|
a514cdc69f
|
|||
|
e822ba1f54
|
|||
|
3c925d8268
|
|||
|
c9073ff4a7
|
|||
|
88324c21c2
|
|||
|
ffc9741d1e
|
|||
| 5638315031 | |||
| 2e43a5e396 | |||
| baaefa2909 | |||
| cf268ebe0f | |||
| e571293f40 | |||
| fa92d90ff4 | |||
| 33aaa020c4 | |||
| bfc1812ebf | |||
| dba6d94eab | |||
| 09d2b6db8c | |||
| 0cbd975748 |
61
CHANGELOG.md
61
CHANGELOG.md
@@ -5,35 +5,52 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.1.7] - 2026-02-14
|
||||
|
||||
### 🐞 错误修复
|
||||
- 修复 `changelog` 命令默认覆盖文件的问题,现改为智能追加新版本条目到头部之后
|
||||
|
||||
### 🔧 其他变更
|
||||
- 清理 `formatter.rs` 中未使用的函数(`format_commit_date`、`format_changelog_date`、`format_tag_name`、`truncate`、`format_markdown_list`、`format_changelog_section`、`format_git_config_key`)
|
||||
- 清理 `validators.rs` 中未使用的函数(`validate_ssh_key`)
|
||||
- 移除 `changelog` 命令的 `--prepend` 参数(默认行为已改为追加)
|
||||
|
||||
## [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
|
||||
|
||||
- Initial release of QuiCommit
|
||||
- AI-powered commit message generation using LLM APIs (OpenAI, Anthropic) or local Ollama
|
||||
- Support for Conventional Commits and @commitlint formats
|
||||
- Multiple Git profile management with SSH and GPG support
|
||||
- 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)
|
||||
- Initial project structure
|
||||
- Core functionality for git operations
|
||||
- LLM integration
|
||||
- Configuration management
|
||||
- CLI interface
|
||||
|
||||
### Features
|
||||
|
||||
- **Commit Generation**: Automatically generate conventional commit messages from git diffs
|
||||
- **Profile Management**: Switch between multiple Git identities for different contexts
|
||||
- **Tag Management**: Create annotated tags with AI-generated release notes
|
||||
- **Changelog**: Generate and maintain changelog in Keep a Changelog format
|
||||
- **Security**: Encrypt SSH passphrases and API keys
|
||||
- **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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "quicommit"
|
||||
version = "0.1.2"
|
||||
version = "0.1.7"
|
||||
edition = "2024"
|
||||
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
||||
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"
|
||||
|
||||
234
README.md
234
README.md
@@ -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.
|
||||
|
||||
[Still in early development, some features may not be complete. Feedback and contributions are welcome.]
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -19,9 +21,19 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
|
||||
|
||||
## Installation
|
||||
|
||||
### Cargo Install
|
||||
|
||||
The cargo-installed version may temporarily lag behind the source code progress.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/quicommit.git
|
||||
cd quicommit
|
||||
cargo install quicommit
|
||||
```
|
||||
|
||||
### Install from Source
|
||||
|
||||
```bash
|
||||
git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
|
||||
cd QuiCommit
|
||||
cargo build --release
|
||||
cargo install --path .
|
||||
```
|
||||
@@ -50,6 +62,12 @@ quicommit commit -a
|
||||
|
||||
# Skip confirmation
|
||||
quicommit commit --yes
|
||||
|
||||
# Use date-based commit message
|
||||
quicommit commit --date
|
||||
|
||||
# Push after committing
|
||||
quicommit commit --push
|
||||
```
|
||||
|
||||
### Create Tag
|
||||
@@ -63,6 +81,12 @@ quicommit tag --bump minor
|
||||
|
||||
# Custom tag name
|
||||
quicommit tag -n v1.0.0
|
||||
|
||||
# AI-generate tag message
|
||||
quicommit tag --generate
|
||||
|
||||
# Create tag and push to remote
|
||||
quicommit tag --push
|
||||
```
|
||||
|
||||
### Generate Changelog
|
||||
@@ -73,6 +97,15 @@ quicommit changelog
|
||||
|
||||
# Generate for specific version
|
||||
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
|
||||
@@ -84,11 +117,41 @@ quicommit profile add
|
||||
# List profiles
|
||||
quicommit profile list
|
||||
|
||||
# Show profile details
|
||||
quicommit profile show
|
||||
|
||||
# Switch profile
|
||||
quicommit profile switch
|
||||
|
||||
# Set default profile
|
||||
quicommit profile set-default personal
|
||||
|
||||
# Set profile for current repo
|
||||
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
|
||||
@@ -109,17 +172,41 @@ quicommit config set-anthropic-key YOUR_API_KEY
|
||||
# Configure Kimi (Moonshot AI)
|
||||
quicommit config set-llm kimi
|
||||
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
|
||||
quicommit config set-llm deepseek
|
||||
quicommit config set-deepseek-key YOUR_API_KEY
|
||||
quicommit config set-deepseek --base-url https://api.deepseek.com/v1 --model deepseek-chat
|
||||
|
||||
# Configure OpenRouter
|
||||
quicommit config set-llm openrouter
|
||||
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
|
||||
quicommit config test-llm
|
||||
|
||||
# Reset configuration to defaults
|
||||
quicommit config reset
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
@@ -137,17 +224,23 @@ quicommit config test-llm
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-t, --commit-type` | Commit type (feat, fix, etc.) |
|
||||
| `--commit-type` | Commit type (feat, fix, etc.) |
|
||||
| `-s, --scope` | Commit scope |
|
||||
| `-m, --message` | Commit description |
|
||||
| `--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 |
|
||||
| `-a, --all` | Stage all changes |
|
||||
| `-S, --sign` | GPG sign commit |
|
||||
| `--amend` | Amend previous commit |
|
||||
| `--dry-run` | Show without committing |
|
||||
| `--conventional` | Use Conventional Commits format |
|
||||
| `--commitlint` | Use commitlint format |
|
||||
| `--no-verify` | Skip commit message verification |
|
||||
| `-y, --yes` | Skip confirmation |
|
||||
| `--push` | Push after committing |
|
||||
| `--remote` | Specify remote repository (default: origin) |
|
||||
|
||||
### Tag Options
|
||||
|
||||
@@ -158,14 +251,35 @@ quicommit config test-llm
|
||||
| `-m, --message` | Tag message |
|
||||
| `-g, --generate` | AI-generate message |
|
||||
| `-S, --sign` | GPG sign tag |
|
||||
| `--lightweight` | Create lightweight tag |
|
||||
| `--push` | Push to remote |
|
||||
| `-l, --lightweight` | Create lightweight tag |
|
||||
| `-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 |
|
||||
|
||||
## Configuration File
|
||||
|
||||
Location:
|
||||
- Linux/macOS: `~/.config/quicommit/config.toml`
|
||||
- Linux: `~/.config/quicommit/config.toml`
|
||||
- macOS: `~/Library/Application Support/quicommit/config.toml`
|
||||
- Windows: `%APPDATA%\quicommit\config.toml`
|
||||
|
||||
```toml
|
||||
@@ -174,15 +288,27 @@ default_profile = "personal"
|
||||
|
||||
[profiles.personal]
|
||||
name = "personal"
|
||||
user_name = "John Doe"
|
||||
user_email = "john@example.com"
|
||||
user_name = "Your Name"
|
||||
user_email = "your.email@example.com"
|
||||
description = "Personal projects"
|
||||
is_work = false
|
||||
|
||||
[profiles.work]
|
||||
name = "work"
|
||||
user_name = "John Doe"
|
||||
user_email = "john@company.com"
|
||||
user_name = "Your Name"
|
||||
user_email = "your.name@company.com"
|
||||
description = "Work projects"
|
||||
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]
|
||||
provider = "ollama"
|
||||
@@ -198,19 +324,50 @@ model = "llama2"
|
||||
model = "gpt-4"
|
||||
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]
|
||||
format = "conventional"
|
||||
auto_generate = true
|
||||
allow_empty = false
|
||||
gpg_sign = false
|
||||
max_subject_length = 100
|
||||
require_scope = false
|
||||
require_body = false
|
||||
body_required_types = ["feat", "fix"]
|
||||
|
||||
[tag]
|
||||
version_prefix = "v"
|
||||
auto_generate = true
|
||||
gpg_sign = false
|
||||
include_changelog = true
|
||||
|
||||
[changelog]
|
||||
path = "CHANGELOG.md"
|
||||
auto_generate = true
|
||||
format = "keep-a-changelog"
|
||||
include_hashes = false
|
||||
include_authors = false
|
||||
group_by_type = true
|
||||
|
||||
[theme]
|
||||
colors = true
|
||||
icons = true
|
||||
date_format = "%Y-%m-%d"
|
||||
|
||||
[repo_profiles]
|
||||
"/path/to/work/project" = "work"
|
||||
"/path/to/personal/project" = "personal"
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
@@ -227,14 +384,32 @@ group_by_type = true
|
||||
# View current configuration
|
||||
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
|
||||
quicommit config test-llm
|
||||
|
||||
# List available models
|
||||
quicommit config list-models
|
||||
|
||||
# Edit configuration
|
||||
quicommit config edit
|
||||
# Export configuration
|
||||
quicommit config export -o config-backup.toml
|
||||
|
||||
# Import configuration
|
||||
quicommit config import -i config-backup.toml
|
||||
|
||||
# Reset configuration
|
||||
quicommit config reset --force
|
||||
```
|
||||
|
||||
## Contributing
|
||||
@@ -253,8 +428,8 @@ Contributions are welcome! Please follow these steps:
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/YOUR_USERNAME/quicommit.git
|
||||
cd quicommit
|
||||
git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
|
||||
cd QuiCommit
|
||||
|
||||
# Fetch dependencies
|
||||
cargo fetch
|
||||
@@ -282,11 +457,36 @@ cargo fmt --check
|
||||
```
|
||||
src/
|
||||
├── commands/ # CLI command implementations
|
||||
│ ├── commit.rs
|
||||
│ ├── tag.rs
|
||||
│ ├── changelog.rs
|
||||
│ ├── profile.rs
|
||||
│ ├── config.rs
|
||||
│ └── init.rs
|
||||
├── config/ # Configuration management
|
||||
│ ├── manager.rs
|
||||
│ └── profile.rs
|
||||
├── generator/ # AI content generation
|
||||
├── git/ # Git operations
|
||||
│ ├── commit.rs
|
||||
│ ├── tag.rs
|
||||
│ └── changelog.rs
|
||||
├── 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
|
||||
|
||||
240
readme_zh.md
240
readme_zh.md
@@ -4,8 +4,10 @@
|
||||
|
||||
一款强大的AI驱动的Git助手,用于生成规范化的提交信息、标签和变更日志,并支持管理多个Git配置。
|
||||
|
||||

|
||||

|
||||
【目前还处在早期开发阶段,依然有一些功能未完善,欢迎反馈和贡献。】
|
||||
|
||||

|
||||

|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -19,11 +21,19 @@
|
||||
|
||||
## 安装
|
||||
|
||||
目前,整体工具还在开发,并不保证各项功能准确达到既定目标。但依然十分欢迎参与贡献、反馈问题和建议。
|
||||
### cargo安装
|
||||
|
||||
cargo安装版本可能暂时不如源码进展快速。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/quicommit.git
|
||||
cd quicommit
|
||||
cargo install quicommit
|
||||
```
|
||||
|
||||
### 从源代码安装
|
||||
|
||||
```bash
|
||||
git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
|
||||
cd QuiCommit
|
||||
cargo build --release
|
||||
cargo install --path .
|
||||
```
|
||||
@@ -52,6 +62,12 @@ quicommit commit -a
|
||||
|
||||
# 跳过确认直接提交
|
||||
quicommit commit --yes
|
||||
|
||||
# 使用日期格式的提交信息
|
||||
quicommit commit --date
|
||||
|
||||
# 提交后推送到远程
|
||||
quicommit commit --push
|
||||
```
|
||||
|
||||
### 创建标签
|
||||
@@ -65,6 +81,12 @@ quicommit tag --bump minor
|
||||
|
||||
# 自定义标签名
|
||||
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
|
||||
|
||||
# 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 show
|
||||
|
||||
# 切换配置
|
||||
quicommit profile switch
|
||||
|
||||
# 设置默认配置
|
||||
quicommit profile set-default 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配置
|
||||
@@ -111,17 +172,41 @@ quicommit config set-anthropic-key YOUR_API_KEY
|
||||
# 配置Kimi
|
||||
quicommit config set-llm kimi
|
||||
quicommit config set-kimi-key YOUR_API_KEY
|
||||
quicommit config set-kimi --base-url https://api.moonshot.cn/v1 --model moonshot-v1-8k
|
||||
|
||||
# 配置DeepSeek
|
||||
quicommit config set-llm deepseek
|
||||
quicommit config set-deepseek-key YOUR_API_KEY
|
||||
quicommit config set-deepseek --base-url https://api.deepseek.com/v1 --model deepseek-chat
|
||||
|
||||
# 配置OpenRouter
|
||||
quicommit config set-llm openrouter
|
||||
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连接
|
||||
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` | 提交范围 |
|
||||
| `-m, --message` | 提交描述 |
|
||||
| `--body` | 提交正文 |
|
||||
| `--breaking` | 标记为破坏性变更 |
|
||||
| `-b, --breaking` | 标记为破坏性变更 |
|
||||
| `-d, --date` | 使用日期格式的提交信息 |
|
||||
| `--manual` | 手动输入,跳过AI生成 |
|
||||
| `-a, --all` | 暂存所有更改 |
|
||||
| `-S, --sign` | GPG签名提交 |
|
||||
| `--amend` | 修改上一次提交 |
|
||||
| `--dry-run` | 试运行,不实际提交 |
|
||||
| `--conventional` | 使用Conventional Commits格式 |
|
||||
| `--commitlint` | 使用commitlint格式 |
|
||||
| `--no-verify` | 不验证提交信息 |
|
||||
| `-y, --yes` | 跳过确认提示 |
|
||||
| `--push` | 提交后推送到远程 |
|
||||
| `--remote` | 指定远程仓库(默认:origin) |
|
||||
|
||||
### tag命令选项
|
||||
|
||||
@@ -160,15 +251,36 @@ quicommit config test-llm
|
||||
| `-m, --message` | 标签信息 |
|
||||
| `-g, --generate` | AI生成标签信息 |
|
||||
| `-S, --sign` | GPG签名标签 |
|
||||
| `--lightweight` | 创建轻量标签 |
|
||||
| `--push` | 推送到远程 |
|
||||
| `-l, --lightweight` | 创建轻量标签 |
|
||||
| `-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` | 跳过确认提示 |
|
||||
|
||||
## 配置文件
|
||||
|
||||
配置文件位置:
|
||||
- Linux/macOS: `~/.config/quicommit/config.toml`
|
||||
- Windows: `%APPDATA%\quicommit\config.toml`
|
||||
- Linux: `~/.config/quicommit/config.toml`
|
||||
- macOS: `~/Library/Application Support/quicommit/config.toml`
|
||||
- Windows: `%APPDATA%\quicommit/config.toml`
|
||||
|
||||
```toml
|
||||
version = "1"
|
||||
@@ -176,15 +288,27 @@ default_profile = "personal"
|
||||
|
||||
[profiles.personal]
|
||||
name = "personal"
|
||||
user_name = "John Doe"
|
||||
user_email = "john@example.com"
|
||||
user_name = "Your Name"
|
||||
user_email = "your.email@example.com"
|
||||
description = "个人项目"
|
||||
is_work = false
|
||||
|
||||
[profiles.work]
|
||||
name = "work"
|
||||
user_name = "John Doe"
|
||||
user_email = "john@company.com"
|
||||
user_name = "Your Name"
|
||||
user_email = "your.name@company.com"
|
||||
description = "工作项目"
|
||||
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]
|
||||
provider = "ollama"
|
||||
@@ -200,19 +324,50 @@ model = "llama2"
|
||||
model = "gpt-4"
|
||||
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]
|
||||
format = "conventional"
|
||||
auto_generate = true
|
||||
allow_empty = false
|
||||
gpg_sign = false
|
||||
max_subject_length = 100
|
||||
require_scope = false
|
||||
require_body = false
|
||||
body_required_types = ["feat", "fix"]
|
||||
|
||||
[tag]
|
||||
version_prefix = "v"
|
||||
auto_generate = true
|
||||
gpg_sign = false
|
||||
include_changelog = true
|
||||
|
||||
[changelog]
|
||||
path = "CHANGELOG.md"
|
||||
auto_generate = true
|
||||
format = "keep-a-changelog"
|
||||
include_hashes = false
|
||||
include_authors = false
|
||||
group_by_type = true
|
||||
|
||||
[theme]
|
||||
colors = true
|
||||
icons = true
|
||||
date_format = "%Y-%m-%d"
|
||||
|
||||
[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 show
|
||||
|
||||
# 编辑配置文件
|
||||
quicommit config edit
|
||||
|
||||
# 设置配置值
|
||||
quicommit config set llm.provider ollama
|
||||
|
||||
# 获取配置值
|
||||
quicommit config get llm.provider
|
||||
|
||||
# 测试LLM连接
|
||||
quicommit config test-llm
|
||||
|
||||
# 列出可用模型
|
||||
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
|
||||
# 克隆仓库
|
||||
git clone https://github.com/YOUR_USERNAME/quicommit.git
|
||||
cd quicommit
|
||||
git clone https://git.lyz.one/SidneyZhang/QuiCommit.git
|
||||
cd QuiCommit
|
||||
|
||||
# 安装依赖
|
||||
cargo fetch
|
||||
@@ -284,11 +457,36 @@ cargo fmt --check
|
||||
```
|
||||
src/
|
||||
├── commands/ # CLI命令实现
|
||||
│ ├── commit.rs
|
||||
│ ├── tag.rs
|
||||
│ ├── changelog.rs
|
||||
│ ├── profile.rs
|
||||
│ ├── config.rs
|
||||
│ └── init.rs
|
||||
├── config/ # 配置管理
|
||||
│ ├── manager.rs
|
||||
│ └── profile.rs
|
||||
├── generator/ # AI内容生成
|
||||
├── git/ # Git操作封装
|
||||
│ ├── commit.rs
|
||||
│ ├── tag.rs
|
||||
│ └── changelog.rs
|
||||
├── 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 # 程序入口
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
@@ -5,20 +5,22 @@ use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{Language, manager::ConfigManager};
|
||||
use crate::generator::ContentGenerator;
|
||||
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
|
||||
#[derive(Parser)]
|
||||
#[command(disable_version_flag = true, disable_help_flag = false)]
|
||||
pub struct ChangelogCommand {
|
||||
/// Output file path
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Version to generate changelog for
|
||||
#[arg(short, long)]
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
|
||||
/// Generate from specific tag
|
||||
@@ -37,10 +39,6 @@ pub struct ChangelogCommand {
|
||||
#[arg(short, long)]
|
||||
generate: bool,
|
||||
|
||||
/// Prepend to existing changelog
|
||||
#[arg(short, long)]
|
||||
prepend: bool,
|
||||
|
||||
/// Include commit hashes
|
||||
#[arg(long)]
|
||||
include_hashes: bool,
|
||||
@@ -50,7 +48,7 @@ pub struct ChangelogCommand {
|
||||
include_authors: bool,
|
||||
|
||||
/// Format (keep-a-changelog, github-releases)
|
||||
#[arg(short, long)]
|
||||
#[arg(long)]
|
||||
format: Option<String>,
|
||||
|
||||
/// Dry run (output to stdout)
|
||||
@@ -63,10 +61,16 @@ pub struct ChangelogCommand {
|
||||
}
|
||||
|
||||
impl ChangelogCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
||||
let manager = ConfigManager::new()?;
|
||||
let manager = if let Some(ref path) = config_path {
|
||||
ConfigManager::with_path(path)?
|
||||
} else {
|
||||
ConfigManager::new()?
|
||||
};
|
||||
let config = manager.config();
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
let messages = Messages::new(language);
|
||||
|
||||
// Initialize changelog if requested
|
||||
if self.init {
|
||||
@@ -75,7 +79,7 @@ impl ChangelogCommand {
|
||||
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
|
||||
|
||||
init_changelog(&path)?;
|
||||
println!("{} Initialized changelog at {:?}", "✓".green(), path);
|
||||
println!("{}", messages.initialized_changelog(&format!("{:?}", path)));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -98,28 +102,28 @@ impl ChangelogCommand {
|
||||
v.clone()
|
||||
} else if !self.yes {
|
||||
Input::new()
|
||||
.with_prompt("Version")
|
||||
.default("Unreleased".to_string())
|
||||
.with_prompt(messages.version())
|
||||
.default(messages.unreleased().to_string())
|
||||
.interact_text()?
|
||||
} else {
|
||||
"Unreleased".to_string()
|
||||
messages.unreleased().to_string()
|
||||
};
|
||||
|
||||
// Get commits
|
||||
println!("{} Fetching commits...", "→".blue());
|
||||
println!("{}", messages.fetching_commits());
|
||||
let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?;
|
||||
|
||||
if commits.is_empty() {
|
||||
bail!("No commits found in the specified range");
|
||||
bail!("{}", messages.no_commits_found());
|
||||
}
|
||||
|
||||
println!("{} Found {} commits", "✓".green(), commits.len());
|
||||
println!("{}", messages.found_commits(commits.len()));
|
||||
|
||||
// Generate changelog
|
||||
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 {
|
||||
self.generate_with_template(format, &version, &commits)?
|
||||
self.generate_with_template(format, &version, &commits, language)?
|
||||
};
|
||||
|
||||
// Output or write
|
||||
@@ -133,7 +137,7 @@ impl ChangelogCommand {
|
||||
// Preview
|
||||
if !self.yes {
|
||||
println!("\n{}", "─".repeat(60));
|
||||
println!("{}", "Changelog preview:".bold());
|
||||
println!("{}", messages.changelog_preview().bold());
|
||||
println!("{}", "─".repeat(60));
|
||||
// Show first 20 lines
|
||||
let preview: String = changelog.lines().take(20).collect::<Vec<_>>().join("\n");
|
||||
@@ -144,43 +148,65 @@ impl ChangelogCommand {
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt(&format!("Write to {:?}?", output_path))
|
||||
.with_prompt(&messages.write_to_file(&format!("{:?}", output_path)))
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", "Cancelled.".yellow());
|
||||
println!("{}", messages.cancelled().yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if self.prepend && output_path.exists() {
|
||||
// Write to file (always prepend to preserve history)
|
||||
if output_path.exists() {
|
||||
let existing = std::fs::read_to_string(&output_path)?;
|
||||
let new_content = format!("{}\n{}", changelog, existing);
|
||||
let new_content = if existing.is_empty() {
|
||||
format!("# Changelog\n\n{}", changelog)
|
||||
} else {
|
||||
let lines: Vec<&str> = existing.lines().collect();
|
||||
let mut header_end = 0;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i == 0 && line.starts_with('#') {
|
||||
header_end = i + 1;
|
||||
} else if line.trim().is_empty() {
|
||||
header_end = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let header = lines[..header_end].join("\n");
|
||||
let rest = lines[header_end..].join("\n");
|
||||
|
||||
format!("{}\n{}\n{}", header, changelog, rest)
|
||||
};
|
||||
std::fs::write(&output_path, new_content)?;
|
||||
} else {
|
||||
std::fs::write(&output_path, changelog)?;
|
||||
let content = format!("# Changelog\n\n{}", changelog);
|
||||
std::fs::write(&output_path, content)?;
|
||||
}
|
||||
|
||||
println!("{} Changelog written to {:?}", "✓".green(), output_path);
|
||||
println!("{} {:?}", messages.changelog_written(), output_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_with_ai(
|
||||
&self,
|
||||
repo: &GitRepo,
|
||||
version: &str,
|
||||
commits: &[CommitInfo],
|
||||
messages: &Messages,
|
||||
) -> Result<String> {
|
||||
let manager = ConfigManager::new()?;
|
||||
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?;
|
||||
generator.generate_changelog_entry(version, commits).await
|
||||
generator.generate_changelog_entry(version, commits, language).await
|
||||
}
|
||||
|
||||
fn generate_with_template(
|
||||
@@ -188,12 +214,43 @@ impl ChangelogCommand {
|
||||
format: ChangelogFormat,
|
||||
version: &str,
|
||||
commits: &[CommitInfo],
|
||||
language: Language,
|
||||
) -> Result<String> {
|
||||
let manager = ConfigManager::new()?;
|
||||
|
||||
let generator = ChangelogGenerator::new()
|
||||
.format(format)
|
||||
.include_hashes(self.include_hashes)
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,21 @@ use anyhow::{bail, Context, Result};
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{Language, manager::ConfigManager};
|
||||
use crate::config::CommitFormat;
|
||||
use crate::generator::ContentGenerator;
|
||||
use crate::git::{find_repo, GitRepo};
|
||||
use crate::git::commit::{CommitBuilder, create_date_commit_message};
|
||||
use crate::i18n::Messages;
|
||||
use crate::utils::validators::get_commit_types;
|
||||
|
||||
/// Generate and execute conventional commits
|
||||
#[derive(Parser)]
|
||||
pub struct CommitCommand {
|
||||
/// Commit type
|
||||
#[arg(short, long)]
|
||||
#[arg(long)]
|
||||
commit_type: Option<String>,
|
||||
|
||||
/// Commit scope
|
||||
@@ -38,7 +40,7 @@ pub struct CommitCommand {
|
||||
date: bool,
|
||||
|
||||
/// Manual input (skip AI generation)
|
||||
#[arg(short, long)]
|
||||
#[arg(long)]
|
||||
manual: bool,
|
||||
|
||||
/// Sign the commit
|
||||
@@ -72,23 +74,37 @@ pub struct CommitCommand {
|
||||
/// Skip interactive prompts
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
|
||||
/// Push after committing
|
||||
#[arg(long)]
|
||||
push: bool,
|
||||
|
||||
/// Remote to push to
|
||||
#[arg(long, default_value = "origin")]
|
||||
remote: String,
|
||||
}
|
||||
|
||||
impl CommitCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
// Find git repository
|
||||
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
||||
|
||||
// Load configuration
|
||||
let manager = if let Some(ref path) = config_path {
|
||||
ConfigManager::with_path(path)?
|
||||
} else {
|
||||
ConfigManager::new()?
|
||||
};
|
||||
let config = manager.config();
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
let messages = Messages::new(language);
|
||||
|
||||
// Check for changes
|
||||
let status = repo.status_summary()?;
|
||||
if status.clean && !self.amend {
|
||||
bail!("No changes to commit. Working tree is clean.");
|
||||
bail!("{}", messages.no_changes());
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
let manager = ConfigManager::new()?;
|
||||
let config = manager.config();
|
||||
|
||||
// Determine commit format
|
||||
let format = if self.conventional {
|
||||
CommitFormat::Conventional
|
||||
@@ -98,10 +114,23 @@ impl CommitCommand {
|
||||
config.commit.format
|
||||
};
|
||||
|
||||
// Auto-add if no files are staged and there are unstaged/untracked changes
|
||||
if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
|
||||
println!("{}", messages.auto_stage_changes().yellow());
|
||||
repo.stage_all()?;
|
||||
println!("{}", messages.staged_all().green());
|
||||
|
||||
// Re-check status after staging to ensure changes are detected
|
||||
let new_status = repo.status_summary()?;
|
||||
if new_status.staged == 0 {
|
||||
bail!("Failed to stage changes. Please try running 'git add -A' manually.");
|
||||
}
|
||||
}
|
||||
|
||||
// Stage all if requested
|
||||
if self.all {
|
||||
repo.stage_all()?;
|
||||
println!("{}", "✓ Staged all changes".green());
|
||||
println!("{}", messages.staged_all().green());
|
||||
}
|
||||
|
||||
// Generate or build commit message
|
||||
@@ -111,12 +140,12 @@ impl CommitCommand {
|
||||
} else if self.manual || self.message.is_some() {
|
||||
// Manual commit
|
||||
self.create_manual_commit(format)?
|
||||
} else if config.commit.auto_generate && !self.yes {
|
||||
} else if config.commit.auto_generate {
|
||||
// AI-generated commit
|
||||
self.generate_commit(&repo, format).await?
|
||||
self.generate_commit(&repo, format, &messages).await?
|
||||
} else {
|
||||
// Interactive commit creation
|
||||
self.create_interactive_commit(format).await?
|
||||
self.create_interactive_commit(format, &messages).await?
|
||||
};
|
||||
|
||||
// Validate message
|
||||
@@ -132,32 +161,32 @@ impl CommitCommand {
|
||||
// Show commit preview
|
||||
if !self.yes {
|
||||
println!("\n{}", "─".repeat(60));
|
||||
println!("{}", "Commit preview:".bold());
|
||||
println!("{}", messages.commit_preview().bold());
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("{}", commit_message);
|
||||
println!("{}", "─".repeat(60));
|
||||
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt("Do you want to proceed with this commit?")
|
||||
.with_prompt(messages.proceed_commit())
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", "Commit cancelled.".yellow());
|
||||
println!("{}", messages.commit_cancelled().yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let result = if self.amend {
|
||||
if self.dry_run {
|
||||
println!("\n{}", "Dry run - commit not amended.".yellow());
|
||||
println!("\n{} {}", messages.dry_run(), "- commit not amended.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
self.amend_commit(&repo, &commit_message)?;
|
||||
None
|
||||
} else {
|
||||
if self.dry_run {
|
||||
println!("\n{}", "Dry run - commit not created.".yellow());
|
||||
println!("\n{} {}", messages.dry_run(), "- commit not created.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
CommitBuilder::new()
|
||||
@@ -167,9 +196,27 @@ impl CommitCommand {
|
||||
};
|
||||
|
||||
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 {
|
||||
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(())
|
||||
@@ -181,12 +228,22 @@ impl CommitCommand {
|
||||
}
|
||||
|
||||
fn create_manual_commit(&self, format: CommitFormat) -> Result<String> {
|
||||
let commit_type = self.commit_type.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Commit type required for manual commit. Use -t <type>"))?;
|
||||
|
||||
let description = self.message.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?;
|
||||
|
||||
// Try to extract commit type from message if not provided
|
||||
let commit_type = if let Some(ref ct) = self.commit_type {
|
||||
ct.clone()
|
||||
} else {
|
||||
// Parse from conventional commit format: "type: description"
|
||||
description
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or("feat")
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let builder = CommitBuilder::new()
|
||||
.commit_type(commit_type)
|
||||
.description(description)
|
||||
@@ -198,7 +255,7 @@ impl CommitCommand {
|
||||
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 config = manager.config();
|
||||
|
||||
@@ -206,53 +263,58 @@ impl CommitCommand {
|
||||
let generator = ContentGenerator::new(&config.llm).await
|
||||
.context("Failed to initialize LLM. Use --manual for manual commit.")?;
|
||||
|
||||
println!("{} AI is analyzing your changes...", "🤖".to_string());
|
||||
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 {
|
||||
generator.generate_commit_from_repo(repo, format).await?
|
||||
// Non-interactive mode: generate directly
|
||||
generator.generate_commit_from_repo(repo, format, language).await?
|
||||
} 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())
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Select type
|
||||
let type_idx = Select::new()
|
||||
.with_prompt("Select commit type")
|
||||
.with_prompt(messages.select_commit_type())
|
||||
.items(types)
|
||||
.interact()?;
|
||||
let commit_type = types[type_idx].to_string();
|
||||
|
||||
// Enter scope (optional)
|
||||
let scope: String = Input::new()
|
||||
.with_prompt("Scope (optional, press Enter to skip)")
|
||||
.with_prompt(messages.scope_optional())
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
let scope = if scope.is_empty() { None } else { Some(scope) };
|
||||
|
||||
// Enter description
|
||||
let description: String = Input::new()
|
||||
.with_prompt("Description")
|
||||
.with_prompt(messages.description())
|
||||
.interact_text()?;
|
||||
|
||||
// Breaking change
|
||||
let breaking = Confirm::new()
|
||||
.with_prompt("Is this a breaking change?")
|
||||
.with_prompt(messages.breaking_change())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
// Add body
|
||||
let add_body = Confirm::new()
|
||||
.with_prompt("Add body to commit?")
|
||||
.with_prompt(messages.add_body())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
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() {
|
||||
None
|
||||
} else {
|
||||
@@ -293,8 +355,23 @@ impl CommitCommand {
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to amend commit: {}", stderr);
|
||||
|
||||
let error_msg = if stderr.is_empty() {
|
||||
if stdout.is_empty() {
|
||||
"GPG signing failed. Please check:\n\
|
||||
1. GPG signing key is configured (git config --get user.signingkey)\n\
|
||||
2. GPG agent is running\n\
|
||||
3. You can sign commits manually (try: git commit --amend -S)".to_string()
|
||||
} else {
|
||||
stdout.to_string()
|
||||
}
|
||||
} else {
|
||||
stderr.to_string()
|
||||
};
|
||||
|
||||
bail!("Failed to amend commit: {}", error_msg);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -2,8 +2,9 @@ use anyhow::{bail, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{Language, manager::ConfigManager};
|
||||
use crate::config::CommitFormat;
|
||||
|
||||
/// Mask API key with asterisks for security
|
||||
@@ -147,6 +148,24 @@ enum ConfigSubcommand {
|
||||
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 {
|
||||
/// Skip confirmation
|
||||
@@ -173,40 +192,60 @@ enum ConfigSubcommand {
|
||||
|
||||
/// Test LLM connection
|
||||
TestLlm,
|
||||
|
||||
/// Show config file path
|
||||
Path,
|
||||
}
|
||||
|
||||
impl ConfigCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
match &self.command {
|
||||
Some(ConfigSubcommand::Show) => self.show_config().await,
|
||||
Some(ConfigSubcommand::List) => self.list_config().await,
|
||||
Some(ConfigSubcommand::Edit) => self.edit_config().await,
|
||||
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value).await,
|
||||
Some(ConfigSubcommand::Get { key }) => self.get_value(key).await,
|
||||
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key).await,
|
||||
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key).await,
|
||||
Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key).await,
|
||||
Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key).await,
|
||||
Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key).await,
|
||||
Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetKimi { base_url, model }) => self.set_kimi(base_url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetDeepSeek { base_url, model }) => self.set_deepseek(base_url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetOpenRouter { base_url, model }) => self.set_openrouter(base_url.as_deref(), model.as_deref()).await,
|
||||
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format).await,
|
||||
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await,
|
||||
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await,
|
||||
Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await,
|
||||
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await,
|
||||
Some(ConfigSubcommand::Import { file }) => self.import_config(file).await,
|
||||
Some(ConfigSubcommand::ListModels) => self.list_models().await,
|
||||
Some(ConfigSubcommand::TestLlm) => self.test_llm().await,
|
||||
None => self.show_config().await,
|
||||
Some(ConfigSubcommand::Show) => self.show_config(&config_path).await,
|
||||
Some(ConfigSubcommand::List) => self.list_config(&config_path).await,
|
||||
Some(ConfigSubcommand::Edit) => self.edit_config(&config_path).await,
|
||||
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value, &config_path).await,
|
||||
Some(ConfigSubcommand::Get { key }) => self.get_value(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetKimiKey { key }) => self.set_kimi_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetDeepSeekKey { key }) => self.set_deepseek_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetOpenRouterKey { key }) => self.set_openrouter_key(key, &config_path).await,
|
||||
Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetKimi { base_url, model }) => self.set_kimi(base_url.as_deref(), model.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetDeepSeek { base_url, model }) => self.set_deepseek(base_url.as_deref(), model.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetOpenRouter { base_url, model }) => self.set_openrouter(base_url.as_deref(), model.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format, &config_path).await,
|
||||
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix, &config_path).await,
|
||||
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path, &config_path).await,
|
||||
Some(ConfigSubcommand::SetLanguage { language }) => self.set_language(language.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::SetKeepTypesEnglish { keep }) => self.set_keep_types_english(*keep, &config_path).await,
|
||||
Some(ConfigSubcommand::SetKeepChangelogTypesEnglish { keep }) => self.set_keep_changelog_types_english(*keep, &config_path).await,
|
||||
Some(ConfigSubcommand::Reset { force }) => self.reset(*force, &config_path).await,
|
||||
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref(), &config_path).await,
|
||||
Some(ConfigSubcommand::Import { file }) => self.import_config(file, &config_path).await,
|
||||
Some(ConfigSubcommand::ListModels) => self.list_models(&config_path).await,
|
||||
Some(ConfigSubcommand::TestLlm) => self.test_llm(&config_path).await,
|
||||
Some(ConfigSubcommand::Path) => self.show_path(&config_path).await,
|
||||
None => self.show_config(&config_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn show_config(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
|
||||
match config_path {
|
||||
Some(path) => ConfigManager::with_path(path),
|
||||
None => ConfigManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn show_path(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
println!("{}", manager.path().display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("{}", "\nQuiCommit Configuration".bold());
|
||||
@@ -268,6 +307,12 @@ impl ConfigCommand {
|
||||
println!(" GPG sign: {}", if config.tag.gpg_sign { "yes".green() } else { "no".red() });
|
||||
println!(" Include changelog: {}", if config.tag.include_changelog { "yes".green() } else { "no".red() });
|
||||
|
||||
println!("\n{}", "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!(" Path: {}", config.changelog.path);
|
||||
println!(" Auto-generate: {}", if config.changelog.auto_generate { "yes".green() } else { "no".red() });
|
||||
@@ -279,8 +324,8 @@ impl ConfigCommand {
|
||||
}
|
||||
|
||||
/// List all configuration information with masked API keys
|
||||
async fn list_config(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn list_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("{}", "\nQuiCommit Configuration".bold());
|
||||
@@ -377,15 +422,15 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_config(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn edit_config(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
crate::utils::editor::edit_file(manager.path())?;
|
||||
println!("{} Configuration updated", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_value(&self, key: &str, value: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_value(&self, key: &str, value: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
match key {
|
||||
"llm.provider" => manager.set_llm_provider(value.to_string()),
|
||||
@@ -423,8 +468,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_value(&self, key: &str) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn get_value(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
let value = match key {
|
||||
@@ -442,8 +487,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_llm(&self, provider: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_llm(&self, provider: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let provider = if let Some(p) = provider {
|
||||
p.to_string()
|
||||
@@ -575,48 +620,48 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_openai_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_openai_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_openai_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} OpenAI API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_anthropic_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_anthropic_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_anthropic_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Anthropic API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_kimi_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_kimi_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_kimi_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Kimi API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_deepseek_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_deepseek_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_deepseek_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} DeepSeek API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_openrouter_key(&self, key: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_openrouter_key(&self, key: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_openrouter_api_key(key.to_string());
|
||||
manager.save()?;
|
||||
println!("{} OpenRouter API key set", "✓".green());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_kimi(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(url) = base_url {
|
||||
manager.set_kimi_base_url(url.to_string());
|
||||
@@ -630,8 +675,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_deepseek(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(url) = base_url {
|
||||
manager.set_deepseek_base_url(url.to_string());
|
||||
@@ -645,8 +690,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_openrouter(&self, base_url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(url) = base_url {
|
||||
manager.set_openrouter_base_url(url.to_string());
|
||||
@@ -660,8 +705,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(u) = url {
|
||||
manager.config_mut().llm.ollama.url = u.to_string();
|
||||
@@ -675,8 +720,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_commit_format(&self, format: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_commit_format(&self, format: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let format = match format {
|
||||
"conventional" => CommitFormat::Conventional,
|
||||
@@ -690,23 +735,71 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_version_prefix(&self, prefix: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_version_prefix(&self, prefix: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_version_prefix(prefix.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Set version prefix to '{}'", "✓".green(), prefix);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_changelog_path(&self, path: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_changelog_path(&self, path: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.set_changelog_path(path.to_string());
|
||||
manager.save()?;
|
||||
println!("{} Set changelog path to {}", "✓".green(), path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reset(&self, force: bool) -> Result<()> {
|
||||
async fn set_language(&self, language: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
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, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
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, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
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, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
if !force {
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt("Are you sure you want to reset all configuration?")
|
||||
@@ -719,7 +812,7 @@ impl ConfigCommand {
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = ConfigManager::new()?;
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.reset();
|
||||
manager.save()?;
|
||||
|
||||
@@ -727,8 +820,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn export_config(&self, output: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn export_config(&self, output: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let toml = manager.export()?;
|
||||
|
||||
if let Some(path) = output {
|
||||
@@ -741,10 +834,10 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import_config(&self, file: &str) -> Result<()> {
|
||||
async fn import_config(&self, file: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let toml = std::fs::read_to_string(file)?;
|
||||
|
||||
let mut manager = ConfigManager::new()?;
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
manager.import(&toml)?;
|
||||
manager.save()?;
|
||||
|
||||
@@ -752,8 +845,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn list_models(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
match config.llm.provider.as_str() {
|
||||
@@ -802,6 +895,105 @@ impl ConfigCommand {
|
||||
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 => {
|
||||
println!("Listing models not supported for provider: {}", provider);
|
||||
}
|
||||
@@ -810,8 +1002,8 @@ impl ConfigCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn test_llm(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn test_llm(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
let config = manager.config();
|
||||
|
||||
println!("Testing LLM connection ({})...", config.llm.provider.cyan());
|
||||
@@ -830,7 +1022,7 @@ impl ConfigCommand {
|
||||
fn main() {
|
||||
+ 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) => {
|
||||
println!("{} Generation test passed", "✓".green());
|
||||
println!("Response: {}", response.description.dimmed());
|
||||
|
||||
@@ -2,10 +2,12 @@ use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::{GitProfile};
|
||||
use crate::config::{GitProfile, Language};
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::profile::{GpgConfig, SshConfig};
|
||||
use crate::i18n::Messages;
|
||||
use crate::utils::validators::validate_email;
|
||||
|
||||
/// Initialize quicommit configuration
|
||||
@@ -21,10 +23,13 @@ pub struct InitCommand {
|
||||
}
|
||||
|
||||
impl InitCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
println!("{}", "🚀 Initializing QuiCommit...".bold().cyan());
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
let messages = Messages::new(Language::English);
|
||||
println!("{}", messages.initializing().bold().cyan());
|
||||
|
||||
let config_path = crate::config::AppConfig::default_path()?;
|
||||
let config_path = config_path.unwrap_or_else(|| {
|
||||
crate::config::AppConfig::default_path().unwrap()
|
||||
});
|
||||
|
||||
// Check if config already exists
|
||||
if config_path.exists() && !self.reset {
|
||||
@@ -38,28 +43,36 @@ impl InitCommand {
|
||||
println!("{}", "Initialization cancelled.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
println!("{}", "Configuration already exists. Use --reset to overwrite.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut manager = if self.reset {
|
||||
ConfigManager::new()?
|
||||
} else {
|
||||
ConfigManager::new().or_else(|_| Ok::<_, anyhow::Error>(ConfigManager::default()))?
|
||||
};
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Create new config manager with fresh config
|
||||
let mut manager = ConfigManager::with_path_fresh(&config_path)?;
|
||||
|
||||
if self.yes {
|
||||
// Quick setup with defaults
|
||||
self.quick_setup(&mut manager).await?;
|
||||
} else {
|
||||
// Interactive setup
|
||||
self.interactive_setup(&mut manager).await?;
|
||||
}
|
||||
|
||||
manager.save()?;
|
||||
|
||||
println!("{}", "✅ QuiCommit initialized successfully!".bold().green());
|
||||
println!("\nConfig file: {}", config_path.display());
|
||||
println!("\nNext steps:");
|
||||
// Get configured language for final messages
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
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!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
|
||||
println!(" 3. Start committing: {}", "quicommit commit".cyan());
|
||||
@@ -90,11 +103,35 @@ impl InitCommand {
|
||||
}
|
||||
|
||||
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
|
||||
let profile_name: String = Input::new()
|
||||
.with_prompt("Profile name")
|
||||
.with_prompt(messages.profile_name())
|
||||
.default("personal".to_string())
|
||||
.interact_text()?;
|
||||
|
||||
@@ -110,12 +147,12 @@ impl InitCommand {
|
||||
.unwrap_or_default();
|
||||
|
||||
let user_name: String = Input::new()
|
||||
.with_prompt("Git user name")
|
||||
.with_prompt(messages.git_user_name())
|
||||
.default(default_name)
|
||||
.interact_text()?;
|
||||
|
||||
let user_email: String = Input::new()
|
||||
.with_prompt("Git user email")
|
||||
.with_prompt(messages.git_user_email())
|
||||
.default(default_email)
|
||||
.validate_with(|input: &String| {
|
||||
validate_email(input).map_err(|e| e.to_string())
|
||||
@@ -123,18 +160,18 @@ impl InitCommand {
|
||||
.interact_text()?;
|
||||
|
||||
let description: String = Input::new()
|
||||
.with_prompt("Profile description (optional)")
|
||||
.with_prompt(messages.profile_description())
|
||||
.allow_empty(true)
|
||||
.interact_text()?;
|
||||
|
||||
let is_work = Confirm::new()
|
||||
.with_prompt("Is this a work profile?")
|
||||
.with_prompt(messages.is_work_profile())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let organization = if is_work {
|
||||
Some(Input::new()
|
||||
.with_prompt("Organization/Company name")
|
||||
.with_prompt(messages.organization_name())
|
||||
.interact_text()?)
|
||||
} else {
|
||||
None
|
||||
@@ -142,24 +179,24 @@ impl InitCommand {
|
||||
|
||||
// SSH configuration
|
||||
let setup_ssh = Confirm::new()
|
||||
.with_prompt("Configure SSH key?")
|
||||
.with_prompt(messages.configure_ssh())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let ssh_config = if setup_ssh {
|
||||
Some(self.setup_ssh_interactive().await?)
|
||||
Some(self.setup_ssh_interactive(&messages).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// GPG configuration
|
||||
let setup_gpg = Confirm::new()
|
||||
.with_prompt("Configure GPG signing?")
|
||||
.with_prompt(messages.configure_gpg())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let gpg_config = if setup_gpg {
|
||||
Some(self.setup_gpg_interactive().await?)
|
||||
Some(self.setup_gpg_interactive(&messages).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -184,7 +221,7 @@ impl InitCommand {
|
||||
manager.set_default_profile(Some(profile_name))?;
|
||||
|
||||
// LLM provider selection
|
||||
println!("\n{}", "Select your preferred LLM provider:".bold());
|
||||
println!("\n{}", messages.select_llm_provider().bold());
|
||||
let providers = vec![
|
||||
"Ollama (local)",
|
||||
"OpenAI",
|
||||
@@ -213,27 +250,27 @@ impl InitCommand {
|
||||
// Configure API key if needed
|
||||
if provider == "openai" {
|
||||
let api_key: String = Input::new()
|
||||
.with_prompt("OpenAI API key")
|
||||
.with_prompt(messages.openai_api_key())
|
||||
.interact_text()?;
|
||||
manager.set_openai_api_key(api_key);
|
||||
} else if provider == "anthropic" {
|
||||
let api_key: String = Input::new()
|
||||
.with_prompt("Anthropic API key")
|
||||
.with_prompt(messages.anthropic_api_key())
|
||||
.interact_text()?;
|
||||
manager.set_anthropic_api_key(api_key);
|
||||
} else if provider == "kimi" {
|
||||
let api_key: String = Input::new()
|
||||
.with_prompt("Kimi API key")
|
||||
.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("DeepSeek API key")
|
||||
.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("OpenRouter API key")
|
||||
.with_prompt(messages.openrouter_api_key())
|
||||
.interact_text()?;
|
||||
manager.set_openrouter_api_key(api_key);
|
||||
}
|
||||
@@ -241,7 +278,7 @@ impl InitCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_ssh_interactive(&self) -> Result<SshConfig> {
|
||||
async fn setup_ssh_interactive(&self, messages: &Messages) -> Result<SshConfig> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let ssh_dir = dirs::home_dir()
|
||||
@@ -249,17 +286,17 @@ impl InitCommand {
|
||||
.unwrap_or_else(|| PathBuf::from("~/.ssh"));
|
||||
|
||||
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())
|
||||
.interact_text()?;
|
||||
|
||||
let has_passphrase = Confirm::new()
|
||||
.with_prompt("Does this key have a passphrase?")
|
||||
.with_prompt(messages.has_passphrase())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
let passphrase = if has_passphrase {
|
||||
Some(crate::utils::password_input("SSH key passphrase")?)
|
||||
Some(crate::utils::password_input(messages.ssh_key_passphrase())?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -274,13 +311,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()
|
||||
.with_prompt("GPG key ID")
|
||||
.with_prompt(messages.gpg_key_id())
|
||||
.interact_text()?;
|
||||
|
||||
let use_agent = Confirm::new()
|
||||
.with_prompt("Use GPG agent?")
|
||||
.with_prompt(messages.use_gpg_agent())
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::{bail, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{GitProfile, TokenConfig, TokenType};
|
||||
@@ -123,27 +124,34 @@ enum TokenSubcommand {
|
||||
}
|
||||
|
||||
impl ProfileCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
match &self.command {
|
||||
Some(ProfileSubcommand::Add) => self.add_profile().await,
|
||||
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name).await,
|
||||
Some(ProfileSubcommand::List) => self.list_profiles().await,
|
||||
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref()).await,
|
||||
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name).await,
|
||||
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name).await,
|
||||
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name).await,
|
||||
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global).await,
|
||||
Some(ProfileSubcommand::Switch) => self.switch_profile().await,
|
||||
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).await,
|
||||
Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command).await,
|
||||
Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref()).await,
|
||||
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref()).await,
|
||||
None => self.list_profiles().await,
|
||||
Some(ProfileSubcommand::Add) => self.add_profile(&config_path).await,
|
||||
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name, &config_path).await,
|
||||
Some(ProfileSubcommand::List) => self.list_profiles(&config_path).await,
|
||||
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref(), &config_path).await,
|
||||
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name, &config_path).await,
|
||||
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name, &config_path).await,
|
||||
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name, &config_path).await,
|
||||
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global, &config_path).await,
|
||||
Some(ProfileSubcommand::Switch) => self.switch_profile(&config_path).await,
|
||||
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to, &config_path).await,
|
||||
Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command, &config_path).await,
|
||||
Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref(), &config_path).await,
|
||||
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref(), &config_path).await,
|
||||
None => self.list_profiles(&config_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_profile(&self) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
fn get_manager(&self, config_path: &Option<PathBuf>) -> Result<ConfigManager> {
|
||||
match config_path {
|
||||
Some(path) => ConfigManager::with_path(path),
|
||||
None => ConfigManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
println!("{}", "\nAdd new profile".bold());
|
||||
println!("{}", "─".repeat(40));
|
||||
@@ -244,8 +252,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_profile(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn remove_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if !manager.has_profile(name) {
|
||||
bail!("Profile '{}' not found", name);
|
||||
@@ -269,8 +277,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_profiles(&self) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn list_profiles(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
let profiles = manager.list_profiles();
|
||||
|
||||
@@ -319,8 +327,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_profile(&self, name: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn show_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile = if let Some(n) = name {
|
||||
manager.get_profile(n)
|
||||
@@ -380,8 +388,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_profile(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn edit_profile(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile = manager.get_profile(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?
|
||||
@@ -420,8 +428,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_default(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_default(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
manager.set_default_profile(Some(name.to_string()))?;
|
||||
manager.save()?;
|
||||
@@ -431,22 +439,30 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_repo(&self, name: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn set_repo(&self, name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
||||
|
||||
let repo_path = repo.path().to_string_lossy().to_string();
|
||||
|
||||
manager.set_repo_profile(repo_path, name.to_string())?;
|
||||
manager.set_repo_profile(repo_path.clone(), name.to_string())?;
|
||||
|
||||
// Get the profile and apply it to the repository
|
||||
let profile = manager.get_profile(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?;
|
||||
|
||||
profile.apply_to_repo(repo.inner())?;
|
||||
manager.record_profile_usage(name, Some(repo_path))?;
|
||||
manager.save()?;
|
||||
|
||||
println!("{} Set '{}' for current repository", "✓".green(), name.cyan());
|
||||
println!("{} Applied profile '{}' to current repository", "✓".green(), name.cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn apply_profile(&self, name: Option<&str>, global: bool, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile_name = if let Some(n) = name {
|
||||
n.to_string()
|
||||
@@ -482,8 +498,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn switch_profile(&self) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn switch_profile(&self, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let profiles: Vec<String> = manager.list_profiles()
|
||||
.into_iter()
|
||||
@@ -519,15 +535,15 @@ impl ProfileCommand {
|
||||
.interact()?;
|
||||
|
||||
if apply {
|
||||
self.apply_profile(Some(selected), false).await?;
|
||||
self.apply_profile(Some(selected), false, config_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_profile(&self, from: &str, to: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn copy_profile(&self, from: &str, to: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
let source = manager.get_profile(from)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))?
|
||||
@@ -547,16 +563,16 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_token_command(&self, cmd: &TokenSubcommand) -> Result<()> {
|
||||
async fn handle_token_command(&self, cmd: &TokenSubcommand, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
match cmd {
|
||||
TokenSubcommand::Add { profile, service } => self.add_token(profile, service).await,
|
||||
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service).await,
|
||||
TokenSubcommand::List { profile } => self.list_tokens(profile).await,
|
||||
TokenSubcommand::Add { profile, service } => self.add_token(profile, service, config_path).await,
|
||||
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service, config_path).await,
|
||||
TokenSubcommand::List { profile } => self.list_tokens(profile, config_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_token(&self, profile_name: &str, service: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn add_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if !manager.has_profile(profile_name) {
|
||||
bail!("Profile '{}' not found", profile_name);
|
||||
@@ -602,8 +618,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_token(&self, profile_name: &str, service: &str) -> Result<()> {
|
||||
let mut manager = ConfigManager::new()?;
|
||||
async fn remove_token(&self, profile_name: &str, service: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let mut manager = self.get_manager(config_path)?;
|
||||
|
||||
if !manager.has_profile(profile_name) {
|
||||
bail!("Profile '{}' not found", profile_name);
|
||||
@@ -627,8 +643,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_tokens(&self, profile_name: &str) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn list_tokens(&self, profile_name: &str, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile = manager.get_profile(profile_name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
|
||||
@@ -654,8 +670,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_profile(&self, name: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn check_profile(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
let profile_name = if let Some(n) = name {
|
||||
n.to_string()
|
||||
@@ -687,8 +703,8 @@ impl ProfileCommand {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_stats(&self, name: Option<&str>) -> Result<()> {
|
||||
let manager = ConfigManager::new()?;
|
||||
async fn show_stats(&self, name: Option<&str>, config_path: &Option<PathBuf>) -> Result<()> {
|
||||
let manager = self.get_manager(config_path)?;
|
||||
|
||||
if let Some(n) = name {
|
||||
let profile = manager.get_profile(n)
|
||||
|
||||
@@ -3,13 +3,15 @@ use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use semver::Version;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::config::{Language, manager::ConfigManager};
|
||||
use crate::git::{find_repo, GitRepo};
|
||||
use crate::generator::ContentGenerator;
|
||||
use crate::git::tag::{
|
||||
bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump,
|
||||
};
|
||||
use crate::i18n::Messages;
|
||||
|
||||
/// Generate and create Git tags
|
||||
#[derive(Parser)]
|
||||
@@ -60,10 +62,16 @@ pub struct TagCommand {
|
||||
}
|
||||
|
||||
impl TagCommand {
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
|
||||
let repo = find_repo(std::env::current_dir()?.as_path())?;
|
||||
let manager = ConfigManager::new()?;
|
||||
let manager = if let Some(ref path) = config_path {
|
||||
ConfigManager::with_path(path)?
|
||||
} else {
|
||||
ConfigManager::new()?
|
||||
};
|
||||
let config = manager.config();
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
let messages = Messages::new(language);
|
||||
|
||||
// Determine tag name
|
||||
let tag_name = if let Some(name) = &self.name {
|
||||
@@ -80,7 +88,7 @@ impl TagCommand {
|
||||
format!("{}{}", prefix, new_version)
|
||||
} else {
|
||||
// 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)
|
||||
@@ -96,7 +104,7 @@ impl TagCommand {
|
||||
.interact()?;
|
||||
|
||||
if !proceed {
|
||||
bail!("Tag creation cancelled");
|
||||
bail!("{}", messages.tag_cancelled());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,16 +116,16 @@ impl TagCommand {
|
||||
} else if let Some(msg) = &self.message {
|
||||
Some(msg.clone())
|
||||
} else if self.generate || (config.tag.auto_generate && !self.yes) {
|
||||
Some(self.generate_tag_message(&repo, &tag_name).await?)
|
||||
Some(self.generate_tag_message(&repo, &tag_name, &messages).await?)
|
||||
} else if !self.yes {
|
||||
Some(self.input_message_interactive(&tag_name)?)
|
||||
Some(self.input_message_interactive(&tag_name, &messages)?)
|
||||
} else {
|
||||
Some(format!("Release {}", tag_name))
|
||||
};
|
||||
|
||||
// Show preview
|
||||
println!("\n{}", "─".repeat(60));
|
||||
println!("{}", "Tag preview:".bold());
|
||||
println!("{}", messages.tag_preview().bold());
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("Name: {}", tag_name.cyan());
|
||||
if let Some(ref msg) = message {
|
||||
@@ -129,18 +137,18 @@ impl TagCommand {
|
||||
|
||||
if !self.yes {
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt("Create this tag?")
|
||||
.with_prompt(messages.create_tag())
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", "Tag creation cancelled.".yellow());
|
||||
println!("{}", messages.tag_cancelled().yellow());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if self.dry_run {
|
||||
println!("\n{}", "Dry run - tag not created.".yellow());
|
||||
println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -153,41 +161,52 @@ impl TagCommand {
|
||||
|
||||
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 {
|
||||
println!("{} Pushing tag to {}...", "→".blue(), &self.remote);
|
||||
println!("{}", messages.pushing_tag(&self.remote));
|
||||
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(())
|
||||
}
|
||||
|
||||
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 {
|
||||
let latest = get_latest_version(repo, prefix)?;
|
||||
|
||||
println!("\n{}", "Version selection:".bold());
|
||||
println!("\n{}", messages.version_selection().bold());
|
||||
|
||||
if let Some(ref version) = latest {
|
||||
println!("Latest version: {}{}", prefix, version);
|
||||
println!("{} {}{}", messages.latest_version(), prefix, version);
|
||||
} else {
|
||||
println!("No existing version tags found");
|
||||
println!("{}", messages.no_existing_version_tags());
|
||||
}
|
||||
|
||||
let options = vec![
|
||||
"Auto-detect bump from commits",
|
||||
"Bump major version",
|
||||
"Bump minor version",
|
||||
"Bump patch version",
|
||||
"Enter custom version",
|
||||
"Enter custom tag name",
|
||||
messages.auto_detect_bump(),
|
||||
messages.bump_major_version(),
|
||||
messages.bump_minor_version(),
|
||||
messages.bump_patch_version(),
|
||||
messages.enter_custom_version(),
|
||||
messages.enter_custom_tag_name(),
|
||||
];
|
||||
|
||||
let selection = Select::new()
|
||||
.with_prompt("Select option")
|
||||
.with_prompt(messages.select_option())
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
@@ -201,10 +220,10 @@ impl TagCommand {
|
||||
.map(|v| bump_version(v, bump, None))
|
||||
.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()
|
||||
.with_prompt("Use this version?")
|
||||
.with_prompt(messages.use_this_version())
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
@@ -233,14 +252,14 @@ impl TagCommand {
|
||||
}
|
||||
4 => {
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Enter version (e.g., 1.2.3)")
|
||||
.with_prompt(messages.enter_version())
|
||||
.interact_text()?;
|
||||
let version = Version::parse(&input)?;
|
||||
return Ok(format!("{}{}", prefix, version));
|
||||
}
|
||||
5 => {
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Enter tag name")
|
||||
.with_prompt(messages.enter_tag_name())
|
||||
.interact_text()?;
|
||||
return Ok(input);
|
||||
}
|
||||
@@ -249,9 +268,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 config = manager.config();
|
||||
let language = manager.get_language().unwrap_or(Language::English);
|
||||
|
||||
// Get commits since last tag
|
||||
let tags = repo.get_tags()?;
|
||||
@@ -265,17 +285,17 @@ impl TagCommand {
|
||||
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?;
|
||||
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 use_editor = Confirm::new()
|
||||
.with_prompt("Open editor for tag message?")
|
||||
.with_prompt(messages.open_editor())
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
@@ -283,7 +303,7 @@ impl TagCommand {
|
||||
crate::utils::editor::edit_content(&default_msg)
|
||||
} else {
|
||||
Ok(Input::new()
|
||||
.with_prompt("Tag message")
|
||||
.with_prompt(messages.tag_message())
|
||||
.default(default_msg)
|
||||
.interact_text()?)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{AppConfig, GitProfile, TokenConfig, TokenType};
|
||||
use super::{AppConfig, GitProfile, TokenConfig};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -19,7 +19,11 @@ impl ConfigManager {
|
||||
|
||||
/// Create config manager with specific path
|
||||
pub fn with_path(path: &Path) -> Result<Self> {
|
||||
let config = AppConfig::load(path)?;
|
||||
let config = if path.exists() {
|
||||
AppConfig::load(path)?
|
||||
} else {
|
||||
AppConfig::default()
|
||||
};
|
||||
Ok(Self {
|
||||
config,
|
||||
config_path: path.to_path_buf(),
|
||||
@@ -27,6 +31,15 @@ impl ConfigManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create config manager with fresh config (ignoring existing)
|
||||
pub fn with_path_fresh(path: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
config: AppConfig::default(),
|
||||
config_path: path.to_path_buf(),
|
||||
modified: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get configuration reference
|
||||
pub fn config(&self) -> &AppConfig {
|
||||
&self.config
|
||||
@@ -391,6 +404,46 @@ impl ConfigManager {
|
||||
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
|
||||
pub fn export(&self) -> Result<String> {
|
||||
toml::to_string_pretty(&self.config)
|
||||
|
||||
@@ -53,6 +53,10 @@ pub struct AppConfig {
|
||||
/// Theme settings
|
||||
#[serde(default)]
|
||||
pub theme: ThemeConfig,
|
||||
|
||||
/// Language settings
|
||||
#[serde(default)]
|
||||
pub language: LanguageConfig,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -68,6 +72,7 @@ impl Default for AppConfig {
|
||||
repo_profiles: HashMap::new(),
|
||||
encrypt_sensitive: true,
|
||||
theme: ThemeConfig::default(),
|
||||
language: LanguageConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,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
|
||||
fn default_version() -> String {
|
||||
"1".to_string()
|
||||
@@ -594,6 +676,10 @@ fn default_date_format() -> String {
|
||||
"%Y-%m-%d".to_string()
|
||||
}
|
||||
|
||||
fn default_output_language() -> String {
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Load configuration from file
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
|
||||
@@ -177,8 +177,17 @@ impl GitProfile {
|
||||
|
||||
if let Some(ref ssh) = self.ssh {
|
||||
if let Some(ref key_path) = ssh.private_key_path {
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i {}", key_path.display()))?;
|
||||
let path_str = key_path.display().to_string();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i '{}'", path_str))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +203,30 @@ impl GitProfile {
|
||||
|
||||
if let Some(key) = self.signing_key() {
|
||||
config.set_str("user.signingkey", key)?;
|
||||
|
||||
if self.settings.auto_sign_commits {
|
||||
config.set_bool("commit.gpgsign", true)?;
|
||||
}
|
||||
|
||||
if self.settings.auto_sign_tags {
|
||||
config.set_bool("tag.gpgsign", true)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ssh) = self.ssh {
|
||||
if let Some(ref key_path) = ssh.private_key_path {
|
||||
let path_str = key_path.display().to_string();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i \"{}\"", path_str.replace('\\', "/")))?;
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
config.set_str("core.sshCommand",
|
||||
&format!("ssh -i '{}'", path_str))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -336,7 +369,15 @@ impl SshConfig {
|
||||
if let Some(ref cmd) = self.ssh_command {
|
||||
Some(cmd.clone())
|
||||
} else if let Some(ref key_path) = self.private_key_path {
|
||||
Some(format!("ssh -i '{}'", key_path.display()))
|
||||
let path_str = key_path.display().to_string();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Some(format!("ssh -i \"{}\"", path_str.replace('\\', "/")))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Some(format!("ssh -i '{}'", path_str))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -496,7 +537,11 @@ pub struct ConfigDifference {
|
||||
}
|
||||
|
||||
fn default_gpg_program() -> String {
|
||||
"gpg".to_string()
|
||||
if cfg!(target_os = "windows") {
|
||||
"gpg.exe".to_string()
|
||||
} else {
|
||||
"gpg".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::config::{CommitFormat, LlmConfig};
|
||||
use crate::config::{CommitFormat, LlmConfig, Language};
|
||||
use crate::git::{CommitInfo, GitRepo};
|
||||
use crate::llm::{GeneratedCommit, LlmClient};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
|
||||
/// Content generator using LLM
|
||||
pub struct ContentGenerator {
|
||||
@@ -27,6 +26,7 @@ impl ContentGenerator {
|
||||
&self,
|
||||
diff: &str,
|
||||
format: CommitFormat,
|
||||
language: Language,
|
||||
) -> Result<GeneratedCommit> {
|
||||
// Truncate diff if too long
|
||||
let max_diff_len = 4000;
|
||||
@@ -36,7 +36,7 @@ impl ContentGenerator {
|
||||
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
|
||||
@@ -44,6 +44,7 @@ impl ContentGenerator {
|
||||
&self,
|
||||
repo: &GitRepo,
|
||||
format: CommitFormat,
|
||||
language: Language,
|
||||
) -> Result<GeneratedCommit> {
|
||||
let diff = repo.get_staged_diff()
|
||||
.context("Failed to get staged diff")?;
|
||||
@@ -52,7 +53,7 @@ impl ContentGenerator {
|
||||
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
|
||||
@@ -60,13 +61,14 @@ impl ContentGenerator {
|
||||
&self,
|
||||
version: &str,
|
||||
commits: &[CommitInfo],
|
||||
language: Language,
|
||||
) -> Result<String> {
|
||||
let commit_messages: Vec<String> = commits
|
||||
.iter()
|
||||
.map(|c| c.subject().to_string())
|
||||
.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
|
||||
@@ -74,6 +76,7 @@ impl ContentGenerator {
|
||||
&self,
|
||||
version: &str,
|
||||
commits: &[CommitInfo],
|
||||
language: Language,
|
||||
) -> Result<String> {
|
||||
let typed_commits: Vec<(String, String)> = commits
|
||||
.iter()
|
||||
@@ -83,7 +86,7 @@ impl ContentGenerator {
|
||||
})
|
||||
.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
|
||||
@@ -92,6 +95,7 @@ impl ContentGenerator {
|
||||
repo: &GitRepo,
|
||||
version: &str,
|
||||
from_tag: Option<&str>,
|
||||
language: Language,
|
||||
) -> Result<String> {
|
||||
let commits = if let Some(tag) = from_tag {
|
||||
repo.get_commits_between(tag, "HEAD")?
|
||||
@@ -99,7 +103,7 @@ impl ContentGenerator {
|
||||
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
|
||||
@@ -107,9 +111,9 @@ impl ContentGenerator {
|
||||
&self,
|
||||
repo: &GitRepo,
|
||||
format: CommitFormat,
|
||||
language: Language,
|
||||
) -> Result<GeneratedCommit> {
|
||||
use dialoguer::{Confirm, Select};
|
||||
use console::Term;
|
||||
use dialoguer::Select;
|
||||
|
||||
let diff = repo.get_staged_diff()?;
|
||||
|
||||
@@ -126,7 +130,7 @@ impl ContentGenerator {
|
||||
|
||||
// Generate initial commit
|
||||
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 {
|
||||
println!("\n{}", "─".repeat(60));
|
||||
@@ -139,7 +143,6 @@ impl ContentGenerator {
|
||||
"✓ Accept and commit",
|
||||
"🔄 Regenerate",
|
||||
"✏️ Edit",
|
||||
"📋 Copy to clipboard",
|
||||
"❌ Cancel",
|
||||
];
|
||||
|
||||
@@ -153,30 +156,19 @@ impl ContentGenerator {
|
||||
0 => return Ok(generated),
|
||||
1 => {
|
||||
println!("Regenerating...");
|
||||
generated = self.generate_commit_message(&diff, format).await?;
|
||||
generated = self.generate_commit_message(&diff, format, language).await?;
|
||||
}
|
||||
2 => {
|
||||
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
|
||||
generated = self.parse_edited_commit(&edited, format)?;
|
||||
}
|
||||
3 => {
|
||||
#[cfg(feature = "clipboard")]
|
||||
{
|
||||
arboard::Clipboard::new()?.set_text(generated.to_conventional())?;
|
||||
println!("Copied to clipboard!");
|
||||
}
|
||||
#[cfg(not(feature = "clipboard"))]
|
||||
{
|
||||
println!("Clipboard feature not enabled");
|
||||
}
|
||||
}
|
||||
4 => anyhow::bail!("Cancelled by user"),
|
||||
3 => anyhow::bail!("Cancelled by user"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_edited_commit(&self, edited: &str, format: CommitFormat) -> Result<GeneratedCommit> {
|
||||
fn parse_edited_commit(&self, edited: &str, _format: CommitFormat) -> Result<GeneratedCommit> {
|
||||
let parsed = crate::git::commit::parse_commit_message(edited);
|
||||
|
||||
Ok(GeneratedCommit {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{CommitInfo, GitRepo};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -232,8 +232,6 @@ impl ChangelogGenerator {
|
||||
let mut breaking = vec![];
|
||||
|
||||
for commit in commits {
|
||||
let msg = commit.subject();
|
||||
|
||||
if commit.message.contains("BREAKING CHANGE") {
|
||||
breaking.push(commit);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::GitRepo;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use chrono::Local;
|
||||
|
||||
/// Commit builder for creating commits
|
||||
@@ -47,6 +47,12 @@ impl CommitBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set scope (optional)
|
||||
pub fn scope_opt(mut self, scope: Option<String>) -> Self {
|
||||
self.scope = scope;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set description
|
||||
pub fn description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
@@ -59,6 +65,12 @@ impl CommitBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set body (optional)
|
||||
pub fn body_opt(mut self, body: Option<String>) -> Self {
|
||||
self.body = body;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set footer
|
||||
pub fn footer(mut self, footer: impl Into<String>) -> Self {
|
||||
self.footer = Some(footer.into());
|
||||
@@ -174,8 +186,23 @@ impl CommitBuilder {
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to amend commit: {}", stderr);
|
||||
|
||||
let error_msg = if stderr.is_empty() {
|
||||
if stdout.is_empty() {
|
||||
"GPG signing failed. Please check:\n\
|
||||
1. GPG signing key is configured (git config --get user.signingkey)\n\
|
||||
2. GPG agent is running\n\
|
||||
3. You can sign commits manually (try: git commit --amend -S)".to_string()
|
||||
} else {
|
||||
stdout.to_string()
|
||||
}
|
||||
} else {
|
||||
stderr.to_string()
|
||||
};
|
||||
|
||||
bail!("Failed to amend commit: {}", error_msg);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
404
src/git/mod.rs
404
src/git/mod.rs
@@ -1,17 +1,173 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Path, PathBuf, Component};
|
||||
use std::collections::HashMap;
|
||||
use tempfile;
|
||||
|
||||
pub mod changelog;
|
||||
pub mod commit;
|
||||
pub mod tag;
|
||||
|
||||
pub use changelog::ChangelogGenerator;
|
||||
pub use commit::CommitBuilder;
|
||||
pub use tag::TagBuilder;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
|
||||
fn normalize_path_for_git2(path: &Path) -> PathBuf {
|
||||
let mut normalized = path.to_path_buf();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.starts_with(r"\\?\") {
|
||||
if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
|
||||
normalized = PathBuf::from(stripped);
|
||||
}
|
||||
}
|
||||
if path_str.starts_with(r"\\?\UNC\") {
|
||||
if let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
|
||||
normalized = PathBuf::from(format!(r"\\{}", stripped));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn get_absolute_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
|
||||
if path.is_absolute() {
|
||||
return Ok(normalize_path_for_git2(path));
|
||||
}
|
||||
|
||||
let current_dir = std::env::current_dir()
|
||||
.with_context(|| "Failed to get current directory")?;
|
||||
|
||||
let absolute = current_dir.join(path);
|
||||
Ok(normalize_path_for_git2(&absolute))
|
||||
}
|
||||
|
||||
fn resolve_path_without_canonicalize(path: &Path) -> PathBuf {
|
||||
let mut components = Vec::new();
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
if !components.is_empty() && components.last() != Some(&Component::ParentDir) {
|
||||
components.pop();
|
||||
} else {
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
Component::CurDir => {}
|
||||
_ => components.push(component),
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = PathBuf::new();
|
||||
for component in components {
|
||||
result.push(component.as_os_str());
|
||||
}
|
||||
|
||||
normalize_path_for_git2(&result)
|
||||
}
|
||||
|
||||
fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
|
||||
let normalized = normalize_path_for_git2(path);
|
||||
|
||||
let discover_opts = git2::RepositoryOpenFlags::empty();
|
||||
let ceiling_dirs: [&str; 0] = [];
|
||||
|
||||
let repo = Repository::open_ext(&normalized, discover_opts, &ceiling_dirs)
|
||||
.or_else(|_| Repository::discover(&normalized))
|
||||
.or_else(|_| Repository::open(&normalized));
|
||||
|
||||
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e))
|
||||
}
|
||||
|
||||
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["rev-parse", "--show-toplevel"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.context("Failed to execute git command")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("git CLI failed to find repository");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let git_root = stdout.trim();
|
||||
|
||||
if git_root.is_empty() {
|
||||
bail!("git CLI returned empty path");
|
||||
}
|
||||
|
||||
let git_root_path = PathBuf::from(git_root);
|
||||
let normalized = normalize_path_for_git2(&git_root_path);
|
||||
|
||||
Repository::open(&normalized)
|
||||
.with_context(|| format!("Failed to open repo from git CLI path: {:?}", normalized))
|
||||
}
|
||||
|
||||
fn diagnose_repo_issue(path: &Path) -> String {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
if !path.exists() {
|
||||
issues.push(format!("Path does not exist: {:?}", path));
|
||||
} else if !path.is_dir() {
|
||||
issues.push(format!("Path is not a directory: {:?}", path));
|
||||
}
|
||||
|
||||
let git_dir = path.join(".git");
|
||||
if git_dir.exists() {
|
||||
if git_dir.is_dir() {
|
||||
issues.push("Found .git directory".to_string());
|
||||
let config_file = git_dir.join("config");
|
||||
if config_file.exists() {
|
||||
issues.push("Git config file exists".to_string());
|
||||
} else {
|
||||
issues.push("WARNING: Git config file missing".to_string());
|
||||
}
|
||||
} else {
|
||||
issues.push("Found .git file (submodule or worktree)".to_string());
|
||||
}
|
||||
} else {
|
||||
issues.push("No .git found in current directory".to_string());
|
||||
|
||||
let mut current = path;
|
||||
let mut depth = 0;
|
||||
while let Some(parent) = current.parent() {
|
||||
depth += 1;
|
||||
if depth > 20 {
|
||||
break;
|
||||
}
|
||||
let parent_git = parent.join(".git");
|
||||
if parent_git.exists() {
|
||||
issues.push(format!("Found .git in parent directory: {:?}", parent));
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.starts_with(r"\\?\") {
|
||||
issues.push("Path has Windows extended-length prefix (\\\\?\\)".to_string());
|
||||
}
|
||||
if path_str.contains('\\') && path_str.contains('/') {
|
||||
issues.push("WARNING: Path has mixed path separators".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(current_dir) = std::env::current_dir() {
|
||||
issues.push(format!("Current working directory: {:?}", current_dir));
|
||||
}
|
||||
|
||||
issues.join("\n ")
|
||||
}
|
||||
|
||||
/// Git repository wrapper with enhanced cross-platform support
|
||||
pub struct GitRepo {
|
||||
repo: Repository,
|
||||
path: PathBuf,
|
||||
@@ -19,28 +175,45 @@ pub struct GitRepo {
|
||||
}
|
||||
|
||||
impl GitRepo {
|
||||
/// Open a git repository
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
let absolute_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
|
||||
let repo = Repository::discover(&absolute_path)
|
||||
.or_else(|_| Repository::open(&absolute_path))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to open git repository at '{:?}'. Please ensure:\n\
|
||||
1. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\
|
||||
2. The path is correct and contains a valid '.git' folder.",
|
||||
absolute_path,
|
||||
absolute_path.display()
|
||||
)
|
||||
let absolute_path = get_absolute_path(path)?;
|
||||
let resolved_path = resolve_path_without_canonicalize(&absolute_path);
|
||||
|
||||
let repo = try_open_repo_with_git2(&resolved_path)
|
||||
.or_else(|git2_err| {
|
||||
try_open_repo_with_git_cli(&resolved_path)
|
||||
.map_err(|cli_err| {
|
||||
let diagnosis = diagnose_repo_issue(&resolved_path);
|
||||
anyhow::anyhow!(
|
||||
"Failed to open git repository:\n\
|
||||
\n\
|
||||
=== git2 Error ===\n {}\n\
|
||||
\n\
|
||||
=== git CLI Error ===\n {}\n\
|
||||
\n\
|
||||
=== Diagnosis ===\n {}\n\
|
||||
\n\
|
||||
=== Suggestions ===\n\
|
||||
1. Ensure you are inside a git repository\n\
|
||||
2. Run: git status (to verify git works)\n\
|
||||
3. Run: git config --global --add safe.directory \"*\"\n\
|
||||
4. Check file permissions",
|
||||
git2_err, cli_err, diagnosis
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
let repo_path = repo.workdir()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| resolved_path.clone());
|
||||
|
||||
let config = repo.config().ok();
|
||||
|
||||
Ok(Self {
|
||||
repo,
|
||||
path: absolute_path,
|
||||
path: normalize_path_for_git2(&repo_path),
|
||||
config,
|
||||
})
|
||||
}
|
||||
@@ -157,28 +330,19 @@ impl GitRepo {
|
||||
|
||||
/// Get staged diff
|
||||
pub fn get_staged_diff(&self) -> Result<String> {
|
||||
let head = self.repo.head().ok();
|
||||
let head_tree = head.as_ref()
|
||||
.and_then(|h| h.peel_to_tree().ok());
|
||||
// Use git CLI to get staged diff for better compatibility
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["diff", "--cached"])
|
||||
.current_dir(&self.path)
|
||||
.output()
|
||||
.with_context(|| "Failed to get staged diff with git command")?;
|
||||
|
||||
let mut index = self.repo.index()?;
|
||||
let index_tree = index.write_tree()?;
|
||||
let index_tree = self.repo.find_tree(index_tree)?;
|
||||
|
||||
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
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to get staged diff: {}", stderr);
|
||||
}
|
||||
|
||||
let diff_text = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
Ok(diff_text)
|
||||
}
|
||||
|
||||
@@ -265,28 +429,21 @@ impl GitRepo {
|
||||
|
||||
/// Stage all changes including subdirectories
|
||||
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<()> {
|
||||
for entry in std::fs::read_dir(current_dir)
|
||||
.with_context(|| format!("Failed to read directory: {:?}", current_dir))?
|
||||
{
|
||||
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(())
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to stage changes: {}", stderr);
|
||||
}
|
||||
|
||||
add_directory_recursive(&mut index, &self.path, &self.path)?;
|
||||
index.write()?;
|
||||
// Force refresh the git2 index to pick up changes from git CLI
|
||||
let _ = self.repo.index()?.write();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -357,19 +514,29 @@ impl GitRepo {
|
||||
let temp_file = tempfile::NamedTempFile::new()?;
|
||||
std::fs::write(temp_file.path(), message)?;
|
||||
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
|
||||
.current_dir(&self.path)
|
||||
.output()?;
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
|
||||
.current_dir(&self.path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to create signed commit: {}", stderr);
|
||||
|
||||
let error_msg = if stderr.is_empty() {
|
||||
if stdout.is_empty() {
|
||||
"GPG signing failed. Please check:\n\
|
||||
1. GPG signing key is configured (git config --get user.signingkey)\n\
|
||||
2. GPG agent is running\n\
|
||||
3. You can sign commits manually (try: git commit -S -m 'test')".to_string()
|
||||
} else {
|
||||
stdout.to_string()
|
||||
}
|
||||
} else {
|
||||
stderr.to_string()
|
||||
};
|
||||
|
||||
bail!("Failed to create signed commit: {}", error_msg);
|
||||
}
|
||||
|
||||
let head = self.repo.head()?;
|
||||
@@ -572,33 +739,50 @@ impl GitRepo {
|
||||
|
||||
/// Get repository status summary
|
||||
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 unstaged = 0;
|
||||
let mut untracked = 0;
|
||||
let mut conflicted = 0;
|
||||
|
||||
for entry in statuses.iter() {
|
||||
let status = entry.status();
|
||||
for line in stdout.lines() {
|
||||
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() ||
|
||||
status.is_index_deleted() || status.is_index_renamed() ||
|
||||
status.is_index_typechange() {
|
||||
staged += 1;
|
||||
}
|
||||
// Staged changes (first column not space)
|
||||
if index_status != ' ' && index_status != '?' {
|
||||
staged += 1;
|
||||
}
|
||||
|
||||
if status.is_wt_modified() || status.is_wt_deleted() ||
|
||||
status.is_wt_renamed() || status.is_wt_typechange() {
|
||||
unstaged += 1;
|
||||
}
|
||||
// Unstaged changes (second column not space)
|
||||
if worktree_status != ' ' && worktree_status != '?' {
|
||||
unstaged += 1;
|
||||
}
|
||||
|
||||
if status.is_wt_new() {
|
||||
untracked += 1;
|
||||
}
|
||||
// Untracked files (both columns are ?)
|
||||
if index_status == '?' && worktree_status == '?' {
|
||||
untracked += 1;
|
||||
}
|
||||
|
||||
if status.is_conflicted() {
|
||||
conflicted += 1;
|
||||
// Conflicted files (both columns are U or DD, AA, etc.)
|
||||
if (index_status == 'U' || worktree_status == 'U') ||
|
||||
(index_status == 'A' && worktree_status == 'A') ||
|
||||
(index_status == 'D' && worktree_status == 'D') {
|
||||
conflicted += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,23 +868,75 @@ impl StatusSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find git repository starting from path and walking up
|
||||
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
|
||||
let start_path = start_path.as_ref();
|
||||
|
||||
if let Ok(repo) = GitRepo::open(start_path) {
|
||||
let absolute_start = get_absolute_path(start_path)?;
|
||||
let resolved_start = resolve_path_without_canonicalize(&absolute_start);
|
||||
|
||||
if let Ok(repo) = GitRepo::open(&resolved_start) {
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
let mut current = start_path;
|
||||
let mut current = resolved_start.as_path();
|
||||
let mut attempted_paths = vec![current.to_string_lossy().to_string()];
|
||||
|
||||
let max_depth = 50;
|
||||
let mut depth = 0;
|
||||
|
||||
while let Some(parent) = current.parent() {
|
||||
depth += 1;
|
||||
if depth > max_depth {
|
||||
break;
|
||||
}
|
||||
|
||||
attempted_paths.push(parent.to_string_lossy().to_string());
|
||||
|
||||
if let Ok(repo) = GitRepo::open(parent) {
|
||||
return Ok(repo);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
bail!("No git repository found starting from {:?}", start_path)
|
||||
if let Ok(output) = std::process::Command::new("git")
|
||||
.args(&["rev-parse", "--show-toplevel"])
|
||||
.current_dir(&resolved_start)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let git_root = stdout.trim();
|
||||
if !git_root.is_empty() {
|
||||
if let Ok(repo) = GitRepo::open(git_root) {
|
||||
return Ok(repo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let diagnosis = diagnose_repo_issue(&resolved_start);
|
||||
|
||||
bail!(
|
||||
"No git repository found.\n\
|
||||
\n\
|
||||
=== Starting Path ===\n {:?}\n\
|
||||
\n\
|
||||
=== Paths Attempted ===\n {}\n\
|
||||
\n\
|
||||
=== Current Directory ===\n {:?}\n\
|
||||
\n\
|
||||
=== Diagnosis ===\n {}\n\
|
||||
\n\
|
||||
=== Suggestions ===\n\
|
||||
1. Ensure you are inside a git repository (run: git status)\n\
|
||||
2. Initialize a new repo: git init\n\
|
||||
3. Clone an existing repo: git clone <url>\n\
|
||||
4. Check if .git directory exists and is accessible",
|
||||
resolved_start,
|
||||
attempted_paths.join("\n "),
|
||||
std::env::current_dir().unwrap_or_default(),
|
||||
diagnosis
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if path is inside a git repository
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::GitRepo;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{bail, Result};
|
||||
use semver::Version;
|
||||
|
||||
/// Tag builder for creating tags
|
||||
@@ -281,8 +281,9 @@ pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()
|
||||
if let Some(remote) = remote {
|
||||
use std::process::Command;
|
||||
|
||||
let refspec = format!(":refs/tags/{}", name);
|
||||
let output = Command::new("git")
|
||||
.args(&["push", remote, ":refs/tags/{}"])
|
||||
.args(&["push", remote, &refspec])
|
||||
.current_dir(repo.path())
|
||||
.output()?;
|
||||
|
||||
|
||||
1151
src/i18n/messages.rs
Normal file
1151
src/i18n/messages.rs
Normal file
File diff suppressed because it is too large
Load Diff
5
src/i18n/mod.rs
Normal file
5
src/i18n/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod messages;
|
||||
pub mod translator;
|
||||
|
||||
pub use messages::Messages;
|
||||
pub use translator::translate_changelog_category;
|
||||
233
src/i18n/translator.rs
Normal file
233
src/i18n/translator.rs
Normal 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)
|
||||
}
|
||||
@@ -70,6 +70,12 @@ impl AnthropicClient {
|
||||
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
|
||||
pub async fn validate_key(&self) -> Result<bool> {
|
||||
let url = "https://api.anthropic.com/v1/messages";
|
||||
|
||||
@@ -82,8 +82,8 @@ impl DeepSeekClient {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Validate API key
|
||||
pub async fn validate_key(&self) -> Result<bool> {
|
||||
/// List available models
|
||||
pub async fn list_models(&self) -> Result<Vec<String>> {
|
||||
let url = format!("{}/models", self.base_url);
|
||||
|
||||
let response = self.client
|
||||
@@ -91,16 +91,44 @@ impl DeepSeekClient {
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to validate DeepSeek API key")?;
|
||||
.context("Failed to list DeepSeek models")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(true)
|
||||
} else if response.status().as_u16() == 401 {
|
||||
Ok(false)
|
||||
} else {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,8 @@ impl KimiClient {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Validate API key
|
||||
pub async fn validate_key(&self) -> Result<bool> {
|
||||
/// List available models
|
||||
pub async fn list_models(&self) -> Result<Vec<String>> {
|
||||
let url = format!("{}/models", self.base_url);
|
||||
|
||||
let response = self.client
|
||||
@@ -91,16 +91,44 @@ impl KimiClient {
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to validate Kimi API key")?;
|
||||
.context("Failed to list Kimi models")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(true)
|
||||
} else if response.status().as_u16() == 401 {
|
||||
Ok(false)
|
||||
} else {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
600
src/llm/mod.rs
600
src/llm/mod.rs
@@ -1,7 +1,7 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use crate::config::Language;
|
||||
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
@@ -120,17 +120,22 @@ impl LlmClient {
|
||||
&self,
|
||||
diff: &str,
|
||||
format: crate::config::CommitFormat,
|
||||
language: Language,
|
||||
) -> Result<GeneratedCommit> {
|
||||
let system_prompt = match format {
|
||||
crate::config::CommitFormat::Conventional => {
|
||||
CONVENTIONAL_COMMIT_SYSTEM_PROMPT
|
||||
}
|
||||
crate::config::CommitFormat::Commitlint => {
|
||||
COMMITLINT_SYSTEM_PROMPT
|
||||
}
|
||||
let system_prompt = get_commit_system_prompt(format, language);
|
||||
|
||||
// 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 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?;
|
||||
|
||||
self.parse_commit_response(&response, format)
|
||||
@@ -141,10 +146,23 @@ impl LlmClient {
|
||||
&self,
|
||||
version: &str,
|
||||
commits: &[String],
|
||||
language: Language,
|
||||
) -> Result<String> {
|
||||
let system_prompt = TAG_MESSAGE_SYSTEM_PROMPT;
|
||||
let system_prompt = get_tag_system_prompt(language);
|
||||
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
|
||||
}
|
||||
@@ -154,8 +172,9 @@ impl LlmClient {
|
||||
&self,
|
||||
version: &str,
|
||||
commits: &[(String, String)], // (type, message)
|
||||
language: Language,
|
||||
) -> Result<String> {
|
||||
let system_prompt = CHANGELOG_SYSTEM_PROMPT;
|
||||
let system_prompt = get_changelog_system_prompt(language);
|
||||
|
||||
let commits_text = commits
|
||||
.iter()
|
||||
@@ -163,7 +182,18 @@ impl LlmClient {
|
||||
.collect::<Vec<_>>()
|
||||
.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
|
||||
}
|
||||
@@ -362,6 +392,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
|
||||
|
||||
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates conventional commit messages.
|
||||
@@ -394,6 +476,186 @@ Rules:
|
||||
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.
|
||||
|
||||
Analyze the git diff and generate a commit message.
|
||||
@@ -412,6 +674,114 @@ Rules:
|
||||
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.
|
||||
|
||||
Given a version number and a list of commits, generate a concise but informative tag message.
|
||||
@@ -432,6 +802,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.
|
||||
|
||||
Given a version and a list of commits, generate a well-formatted changelog section.
|
||||
@@ -445,10 +935,80 @@ Group commits by:
|
||||
Format in markdown with proper headings and bullet points.
|
||||
"#;
|
||||
|
||||
/// 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")
|
||||
}
|
||||
const CHANGELOG_SYSTEM_PROMPT_ZH: &str = r#"你是一个生成变更日志条目的助手。
|
||||
|
||||
给定版本和提交列表,生成格式良好的变更日志部分。
|
||||
|
||||
按以下方式分组提交:
|
||||
- 功能 (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.
|
||||
"#;
|
||||
|
||||
@@ -82,8 +82,8 @@ impl OpenRouterClient {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Validate API key
|
||||
pub async fn validate_key(&self) -> Result<bool> {
|
||||
/// List available models
|
||||
pub async fn list_models(&self) -> Result<Vec<String>> {
|
||||
let url = format!("{}/models", self.base_url);
|
||||
|
||||
let response = self.client
|
||||
@@ -93,16 +93,44 @@ impl OpenRouterClient {
|
||||
.header("X-Title", "QuiCommit")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to validate OpenRouter API key")?;
|
||||
.context("Failed to list OpenRouter models")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(true)
|
||||
} else if response.status().as_u16() == 401 {
|
||||
Ok(false)
|
||||
} else {
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +239,7 @@ pub const OPENROUTER_MODELS: &[&str] = &[
|
||||
];
|
||||
|
||||
/// Check if a model name is valid
|
||||
pub fn is_valid_model(model: &str) -> bool {
|
||||
pub fn is_valid_model(_model: &str) -> bool {
|
||||
// Since OpenRouter supports many models, we'll allow any model name
|
||||
// but provide some popular ones as suggestions
|
||||
true
|
||||
|
||||
18
src/main.rs
18
src/main.rs
@@ -1,11 +1,13 @@
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use tracing::debug;
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
mod generator;
|
||||
mod git;
|
||||
mod i18n;
|
||||
mod llm;
|
||||
mod utils;
|
||||
|
||||
@@ -73,7 +75,6 @@ enum Commands {
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize logging
|
||||
let log_level = match cli.verbose {
|
||||
0 => "warn",
|
||||
1 => "info",
|
||||
@@ -88,13 +89,14 @@ async fn main() -> Result<()> {
|
||||
|
||||
debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Execute command
|
||||
let config_path: Option<PathBuf> = cli.config.map(PathBuf::from);
|
||||
|
||||
match cli.command {
|
||||
Commands::Init(cmd) => cmd.execute().await,
|
||||
Commands::Commit(cmd) => cmd.execute().await,
|
||||
Commands::Tag(cmd) => cmd.execute().await,
|
||||
Commands::Changelog(cmd) => cmd.execute().await,
|
||||
Commands::Profile(cmd) => cmd.execute().await,
|
||||
Commands::Config(cmd) => cmd.execute().await,
|
||||
Commands::Init(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Commit(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Tag(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Changelog(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Profile(cmd) => cmd.execute(config_path).await,
|
||||
Commands::Config(cmd) => cmd.execute(config_path).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,22 @@ pub fn get_editor() -> String {
|
||||
.or_else(|_| std::env::var("VISUAL"))
|
||||
.unwrap_or_else(|_| {
|
||||
if cfg!(target_os = "windows") {
|
||||
if let Ok(code) = which::which("code") {
|
||||
return "code --wait".to_string();
|
||||
}
|
||||
if let Ok(notepad) = which::which("notepad") {
|
||||
return "notepad".to_string();
|
||||
}
|
||||
"notepad".to_string()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
if which::which("code").is_ok() {
|
||||
return "code --wait".to_string();
|
||||
}
|
||||
"vi".to_string()
|
||||
} else {
|
||||
if which::which("nano").is_ok() {
|
||||
return "nano".to_string();
|
||||
}
|
||||
"vi".to_string()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use regex::Regex;
|
||||
|
||||
/// Format commit message with conventional commit format
|
||||
@@ -12,7 +11,6 @@ pub fn format_conventional_commit(
|
||||
) -> String {
|
||||
let mut message = String::new();
|
||||
|
||||
// Type and scope
|
||||
message.push_str(commit_type);
|
||||
if let Some(s) = scope {
|
||||
message.push_str(&format!("({})", s));
|
||||
@@ -22,12 +20,10 @@ pub fn format_conventional_commit(
|
||||
}
|
||||
message.push_str(&format!(": {}", description));
|
||||
|
||||
// Body
|
||||
if let Some(b) = body {
|
||||
message.push_str(&format!("\n\n{}", b));
|
||||
}
|
||||
|
||||
// Footer
|
||||
if let Some(f) = footer {
|
||||
message.push_str(&format!("\n\n{}", f));
|
||||
}
|
||||
@@ -46,26 +42,22 @@ pub fn format_commitlint_commit(
|
||||
) -> String {
|
||||
let mut message = String::new();
|
||||
|
||||
// Header
|
||||
message.push_str(commit_type);
|
||||
if let Some(s) = scope {
|
||||
message.push_str(&format!("({})", s));
|
||||
}
|
||||
message.push_str(&format!(": {}", subject));
|
||||
|
||||
// References
|
||||
if let Some(refs) = references {
|
||||
for reference in refs {
|
||||
message.push_str(&format!(" #{}", reference));
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
if let Some(b) = body {
|
||||
message.push_str(&format!("\n\n{}", b));
|
||||
}
|
||||
|
||||
// Footer
|
||||
if let Some(f) = footer {
|
||||
message.push_str(&format!("\n\n{}", f));
|
||||
}
|
||||
@@ -73,38 +65,11 @@ pub fn format_commitlint_commit(
|
||||
message
|
||||
}
|
||||
|
||||
/// Format date for commit message
|
||||
pub fn format_commit_date(date: &DateTime<Local>) -> String {
|
||||
date.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Format date for changelog
|
||||
pub fn format_changelog_date(date: &DateTime<Utc>) -> String {
|
||||
date.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
/// Format tag name with version
|
||||
pub fn format_tag_name(version: &str, prefix: Option<&str>) -> String {
|
||||
match prefix {
|
||||
Some(p) => format!("{}{}", p, version),
|
||||
None => version.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap text at specified width
|
||||
pub fn wrap_text(text: &str, width: usize) -> String {
|
||||
textwrap::fill(text, width)
|
||||
}
|
||||
|
||||
/// Truncate text with ellipsis
|
||||
pub fn truncate(text: &str, max_len: usize) -> String {
|
||||
if text.len() <= max_len {
|
||||
text.to_string()
|
||||
} else {
|
||||
format!("{}...", &text[..max_len.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean commit message (remove comments, extra whitespace)
|
||||
pub fn clean_message(message: &str) -> String {
|
||||
let comment_regex = Regex::new(r"^#.*$").unwrap();
|
||||
@@ -118,44 +83,6 @@ pub fn clean_message(message: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Format list as markdown bullet points
|
||||
pub fn format_markdown_list(items: &[String]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| format!("- {}", item))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Format changelog section
|
||||
pub fn format_changelog_section(
|
||||
version: &str,
|
||||
date: &str,
|
||||
changes: &[(String, Vec<String>)],
|
||||
) -> String {
|
||||
let mut section = format!("## [{}] - {}\n\n", version, date);
|
||||
|
||||
for (category, items) in changes {
|
||||
if !items.is_empty() {
|
||||
section.push_str(&format!("### {}\n\n", category));
|
||||
for item in items {
|
||||
section.push_str(&format!("- {}\n", item));
|
||||
}
|
||||
section.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
section
|
||||
}
|
||||
|
||||
/// Format git config key
|
||||
pub fn format_git_config_key(section: &str, subsection: Option<&str>, key: &str) -> String {
|
||||
match subsection {
|
||||
Some(sub) => format!("{}.{}.{}", section, sub, key),
|
||||
None => format!("{}.{}", section, key),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -189,10 +116,4 @@ mod tests {
|
||||
|
||||
assert!(msg.starts_with("feat!: change API response format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("hello", 10), "hello");
|
||||
assert_eq!(truncate("hello world", 8), "hello...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,6 @@ lazy_static! {
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for SSH key validation (basic)
|
||||
static ref SSH_KEY_REGEX: Regex = Regex::new(
|
||||
r"^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/]+={0,2}\s+.*$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for GPG key ID validation
|
||||
static ref GPG_KEY_ID_REGEX: Regex = Regex::new(
|
||||
r"^[A-F0-9]{16,40}$"
|
||||
@@ -81,7 +76,6 @@ pub fn validate_conventional_commit(message: &str) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// Check description length (max 100 chars for first line)
|
||||
if first_line.len() > 100 {
|
||||
bail!("Commit subject too long (max 100 characters)");
|
||||
}
|
||||
@@ -93,7 +87,6 @@ pub fn validate_conventional_commit(message: &str) -> Result<()> {
|
||||
pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
let first_line = message.lines().next().unwrap_or("");
|
||||
|
||||
// Commitlint is more lenient but still requires type prefix
|
||||
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
bail!("Invalid commit format. Expected: <type>[optional scope]: <subject>");
|
||||
@@ -102,7 +95,6 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
let type_part = parts[0];
|
||||
let subject = parts[1].trim();
|
||||
|
||||
// Extract type (handle scope and breaking indicator)
|
||||
let commit_type = type_part
|
||||
.split('(')
|
||||
.next()
|
||||
@@ -117,7 +109,6 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate subject
|
||||
if subject.is_empty() {
|
||||
bail!("Commit subject cannot be empty");
|
||||
}
|
||||
@@ -130,12 +121,10 @@ pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
bail!("Commit subject too long (max 100 characters)");
|
||||
}
|
||||
|
||||
// Subject should not start with uppercase
|
||||
if subject.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
|
||||
bail!("Commit subject should not start with uppercase letter");
|
||||
}
|
||||
|
||||
// Subject should not end with period
|
||||
if subject.ends_with('.') {
|
||||
bail!("Commit subject should not end with a period");
|
||||
}
|
||||
@@ -179,15 +168,6 @@ pub fn validate_email(email: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate SSH key format
|
||||
pub fn validate_ssh_key(key: &str) -> Result<()> {
|
||||
if !SSH_KEY_REGEX.is_match(key.trim()) {
|
||||
bail!("Invalid SSH public key format");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate GPG key ID
|
||||
pub fn validate_gpg_key_id(key_id: &str) -> Result<()> {
|
||||
if !GPG_KEY_ID_REGEX.is_match(key_id) {
|
||||
|
||||
@@ -1,64 +1,642 @@
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_cli_help() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.arg("--help");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("QuiCommit"));
|
||||
fn create_git_repo(dir: &PathBuf) -> std::process::Output {
|
||||
std::process::Command::new("git")
|
||||
.args(&["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to init git repo")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.arg("--version");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("0.1.0"));
|
||||
fn configure_git_user(dir: &PathBuf) {
|
||||
std::process::Command::new("git")
|
||||
.args(&["config", "user.name", "Test User"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to configure git user name");
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(&["config", "user.email", "test@example.com"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to configure git user email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_show() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_dir = temp_dir.path().join("config");
|
||||
fs::create_dir(&config_dir).unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.env("QUICOMMIT_CONFIG", config_dir.join("config.toml"))
|
||||
.arg("config")
|
||||
.arg("show");
|
||||
|
||||
cmd.assert().success();
|
||||
fn create_test_file(dir: &PathBuf, name: &str, content: &str) {
|
||||
let file_path = dir.join(name);
|
||||
fs::write(&file_path, content).expect("Failed to create test file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_list_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml"))
|
||||
.arg("profile")
|
||||
.arg("list");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("No profiles configured"));
|
||||
fn stage_file(dir: &PathBuf, name: &str) {
|
||||
std::process::Command::new("git")
|
||||
.args(&["add", name])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to stage file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_quick() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml"))
|
||||
.arg("init")
|
||||
.arg("--yes");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("initialized successfully"));
|
||||
fn create_commit(dir: &PathBuf, message: &str) {
|
||||
std::process::Command::new("git")
|
||||
.args(&["commit", "-m", message])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("Failed to create commit");
|
||||
}
|
||||
|
||||
mod cli_basic {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_help() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.arg("--help");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("QuiCommit"))
|
||||
.stdout(predicate::str::contains("AI-powered Git assistant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.arg("--version");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("quicommit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_args_shows_help() {
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verbose_flag() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
let config_path = repo_path.join("config.toml");
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["-vv", "init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
}
|
||||
|
||||
mod init_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_init_quick() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("initialized successfully"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_creates_config_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
assert!(config_path.exists(), "Config file should be created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_in_git_repo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let config_path = repo_path.join("test_config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_reset() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--reset", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("initialized successfully"));
|
||||
}
|
||||
}
|
||||
|
||||
mod profile_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_profile_list_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("No profiles"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_list_with_profile() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("default"));
|
||||
}
|
||||
}
|
||||
|
||||
mod config_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_show() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["config", "show", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Configuration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("config.toml"));
|
||||
}
|
||||
}
|
||||
|
||||
mod commit_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_commit_no_repo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(temp_dir.path());
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_no_changes() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--manual", "-m", "test: empty", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_with_staged_changes() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "Hello, World!");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--manual", "-m", "test: add test file", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_date_mode() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "daily.txt", "Daily update");
|
||||
stage_file(&repo_path, "daily.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--date", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
}
|
||||
|
||||
mod tag_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tag_no_repo() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["tag", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(temp_dir.path());
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_list_empty() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
create_commit(&repo_path, "feat: initial commit");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["tag", "--name", "v0.1.0", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("v0.1.0"));
|
||||
}
|
||||
}
|
||||
|
||||
mod changelog_command {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_changelog_init() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
let changelog_path = repo_path.join("CHANGELOG.md");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["changelog", "--init", "--output", changelog_path.to_str().unwrap(), "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
assert!(changelog_path.exists(), "Changelog file should be created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_changelog_dry_run() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
create_commit(&repo_path, "feat: add feature");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["changelog", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success();
|
||||
}
|
||||
}
|
||||
|
||||
mod cross_platform {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_path_handling_windows_style() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("subdir").join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert().success();
|
||||
assert!(config_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_spaces_in_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let space_dir = temp_dir.path().join("path with spaces");
|
||||
fs::create_dir_all(&space_dir).unwrap();
|
||||
let config_path = space_dir.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert().success();
|
||||
assert!(config_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_with_unicode_path() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let unicode_dir = temp_dir.path().join("路径测试");
|
||||
fs::create_dir_all(&unicode_dir).unwrap();
|
||||
let config_path = unicode_dir.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert().success();
|
||||
assert!(config_path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
mod git_operations {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_git_repo_detection() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let git_dir = repo_path.join(".git");
|
||||
assert!(git_dir.exists(), ".git directory should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_status_clean() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["status", "--porcelain"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.expect("Failed to run git status");
|
||||
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_commit_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
create_commit(&repo_path, "feat: initial commit");
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["log", "--oneline"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.expect("Failed to run git log");
|
||||
|
||||
assert!(output.status.success());
|
||||
let log = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(log.contains("initial commit"));
|
||||
}
|
||||
}
|
||||
|
||||
mod validators {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_commit_message_validation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--manual", "-m", "invalid commit message without type", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("Invalid").or(predicate::str::contains("format")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_conventional_commit() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["commit", "--manual", "-m", "feat: add new feature", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
}
|
||||
|
||||
mod subcommands {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_commit_alias() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let repo_path = temp_dir.path().to_path_buf();
|
||||
create_git_repo(&repo_path);
|
||||
configure_git_user(&repo_path);
|
||||
|
||||
create_test_file(&repo_path, "test.txt", "content");
|
||||
stage_file(&repo_path, "test.txt");
|
||||
|
||||
let config_path = repo_path.join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["c", "--manual", "-m", "fix: test", "--dry-run", "--yes", "--config", config_path.to_str().unwrap()])
|
||||
.current_dir(&repo_path);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Dry run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_alias() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["i", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("initialized successfully"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_alias() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.toml");
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
|
||||
cmd.assert().success();
|
||||
|
||||
let mut cmd = Command::cargo_bin("quicommit").unwrap();
|
||||
cmd.args(&["p", "list", "--config", config_path.to_str().unwrap()]);
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("default"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user