Compare commits
4 Commits
995d263a48
...
v0.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
cc80604710
|
|||
|
35fe6e09b7
|
|||
|
349ff56299
|
|||
|
206bde0786
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -23,4 +23,9 @@ test_output/
|
|||||||
# Config (for development)
|
# Config (for development)
|
||||||
config.toml
|
config.toml
|
||||||
.claude/
|
.claude/
|
||||||
|
.qoder/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
**/agents/
|
||||||
|
adr/
|
||||||
|
.scratch/
|
||||||
|
CONTEXT.md
|
||||||
81
AGENTS.md
Normal file
81
AGENTS.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||||
|
|
||||||
|
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||||
|
|
||||||
|
**Important:** Use Chinese for information responses and thinking; use English for searching and querying.
|
||||||
|
|
||||||
|
## 1. Think Before Coding
|
||||||
|
|
||||||
|
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||||
|
|
||||||
|
Before implementing:
|
||||||
|
- State your assumptions explicitly. If uncertain, ask.
|
||||||
|
- If multiple interpretations exist, present them - don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
## 2. Simplicity First
|
||||||
|
|
||||||
|
**Minimum code that solves the problem. Nothing speculative.**
|
||||||
|
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
- If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||||
|
|
||||||
|
## 3. Surgical Changes
|
||||||
|
|
||||||
|
**Touch only what you must. Clean up only your own mess.**
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it - don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
|
## 4. Goal-Driven Execution
|
||||||
|
|
||||||
|
**Define success criteria. Loop until verified.**
|
||||||
|
|
||||||
|
Transform tasks into verifiable goals:
|
||||||
|
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||||
|
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||||
|
- "Refactor X" → "Ensure tests pass before and after"
|
||||||
|
|
||||||
|
For multi-step tasks, state a brief plan:
|
||||||
|
```
|
||||||
|
1. [Step] → verify: [check]
|
||||||
|
2. [Step] → verify: [check]
|
||||||
|
3. [Step] → verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||||
|
|
||||||
|
## Agent skills
|
||||||
|
|
||||||
|
### Issue tracker
|
||||||
|
|
||||||
|
Issues and specs live as local markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`.
|
||||||
|
|
||||||
|
### Triage labels
|
||||||
|
|
||||||
|
The five canonical triage roles use their default label strings. See `docs/agents/triage-labels.md`.
|
||||||
|
|
||||||
|
### Domain docs
|
||||||
|
|
||||||
|
Single-context repo with `CONTEXT.md` and `docs/adr/` at the root. See `docs/agents/domain.md`.
|
||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
暂无。
|
暂无。
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-07-24
|
||||||
|
|
||||||
|
### ✨ 新功能
|
||||||
|
- `commit` 命令新增 `.gitignore` 文件过滤:自动暂存与 `--all` 模式下自动跳过被 `.gitignore` 规则匹配的文件,并清理索引中已被忽略的已跟踪文件,暂存完成后列出被移除的文件
|
||||||
|
- `tag` 命令新增 `-A, --auto` 模式:优先从 `Cargo.toml`/`pyproject.toml` 读取项目版本,回退到基于 commit 的语义化升级分析(与 `--bump` 互斥)
|
||||||
|
- `changelog` 命令在未指定 `--from` 时,自动检测现有 changelog 中最高版本对应的 tag 作为起始点
|
||||||
|
- 新增 `parse_changelog_versions()`,按 semver 降序提取 changelog 中已有的版本
|
||||||
|
- 新增 `sort_tags_by_semver()`,取代纯时间排序,按语义版本正确排序标签
|
||||||
|
|
||||||
|
### 🐞 错误修复
|
||||||
|
- 修复 `get_tags()` 对 annotated tag 的解析:改用 `find_object`+`peel_to_commit` 正确取得标签指向的 commit
|
||||||
|
- 修复 `changelog` 写入时 `insert_changelog_entry` 覆盖已有章节的问题,现保留现有内容并按版本插入新条目
|
||||||
|
|
||||||
|
### 📚 文档
|
||||||
|
- 同步更新 README(中/英文):补充 `--think`、`tag --auto` 选项说明,移除已废弃的 `--prepend` 参数,修正 `set-llm` 的 `--base-url` 选项名
|
||||||
|
|
||||||
|
### 🔧 其他变更
|
||||||
|
- 新增 `TagInfo::version_name()` 与 `GitRepo::find_tag_by_version()` 辅助方法
|
||||||
|
- 新增 `tests/gitignore_tests.rs`,包含 7 个测试覆盖 `.gitignore` 过滤逻辑
|
||||||
|
|
||||||
## [0.4.0] - 2026-07-16
|
## [0.4.0] - 2026-07-16
|
||||||
|
|
||||||
### ✨ 新功能
|
### ✨ 新功能
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "quicommit"
|
name = "quicommit"
|
||||||
version = "0.4.1"
|
version = "0.5.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
||||||
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
|
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
|
||||||
@@ -47,6 +47,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
|||||||
# Utilities
|
# Utilities
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
|
roxmltree = "0.20"
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
colored = "2.1"
|
colored = "2.1"
|
||||||
handlebars = "5.1"
|
handlebars = "5.1"
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -18,7 +18,7 @@ A powerful AI-powered Git assistant for generating conventional commits, tags, a
|
|||||||
- **AI-Powered Generation**: Generate commits, tags, and changelogs using LLM APIs (Ollama, OpenAI, Anthropic, Kimi, DeepSeek, OpenRouter) or local models
|
- **AI-Powered Generation**: Generate commits, tags, and changelogs using LLM APIs (Ollama, OpenAI, Anthropic, Kimi, DeepSeek, OpenRouter) or local models
|
||||||
- **Conventional Commits**: Full support for Conventional Commits and commitlint formats
|
- **Conventional Commits**: Full support for Conventional Commits and commitlint formats
|
||||||
- **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support
|
- **Profile Management**: Manage multiple Git identities with SSH keys and GPG signing support
|
||||||
- **Smart Tagging**: Semantic version bumping with AI-generated release notes
|
- **Smart Tagging**: Semantic version bumping with AI-generated release notes; auto-detects version from 16+ project config files (Cargo.toml, package.json, pom.xml, pubspec.yaml, etc.)
|
||||||
- **Changelog Generation**: Automatic changelog generation in Keep a Changelog format
|
- **Changelog Generation**: Automatic changelog generation in Keep a Changelog format
|
||||||
- **Security**: Use system keyring to store API keys securely
|
- **Security**: Use system keyring to store API keys securely
|
||||||
- **Interactive UI**: Beautiful CLI with previews and confirmations
|
- **Interactive UI**: Beautiful CLI with previews and confirmations
|
||||||
@@ -63,7 +63,7 @@ quicommit commit
|
|||||||
# Manual commit
|
# Manual commit
|
||||||
quicommit commit --manual -t feat -m "add new feature"
|
quicommit commit --manual -t feat -m "add new feature"
|
||||||
|
|
||||||
# Stage all and commit
|
# Stage all and commit (skips .gitignore-matched files automatically)
|
||||||
quicommit commit -a
|
quicommit commit -a
|
||||||
|
|
||||||
# Skip confirmation
|
# Skip confirmation
|
||||||
@@ -72,16 +72,25 @@ quicommit commit --yes
|
|||||||
# Use date-based commit message
|
# Use date-based commit message
|
||||||
quicommit commit --date
|
quicommit commit --date
|
||||||
|
|
||||||
|
# Enable LLM thinking/reasoning mode for this commit
|
||||||
|
quicommit commit --think
|
||||||
|
|
||||||
# Push after committing
|
# Push after committing
|
||||||
quicommit commit --push
|
quicommit commit --push
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When staging changes (auto-stage or `--all`), files matched by `.gitignore` rules are skipped and any ignored files already in the index are removed; the skipped list is printed for review.
|
||||||
|
|
||||||
### Create Tag
|
### Create Tag
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Auto-detect version bump
|
# Auto-detect version bump
|
||||||
quicommit tag
|
quicommit tag
|
||||||
|
|
||||||
|
# Auto-detect version from project config files (Cargo.toml, package.json, pom.xml, etc.),
|
||||||
|
# fall back to commit analysis
|
||||||
|
quicommit tag --auto
|
||||||
|
|
||||||
# Specify bump type
|
# Specify bump type
|
||||||
quicommit tag --bump minor
|
quicommit tag --bump minor
|
||||||
|
|
||||||
@@ -98,11 +107,14 @@ quicommit tag --push
|
|||||||
### Generate Changelog
|
### Generate Changelog
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate for unreleased changes
|
# Generate for unreleased changes (auto-detects --from from the highest version in the existing changelog)
|
||||||
quicommit changelog
|
quicommit changelog
|
||||||
|
|
||||||
# Generate for specific version
|
# Generate for specific version
|
||||||
quicommit changelog -v 1.0.0
|
quicommit changelog --version 1.0.0
|
||||||
|
|
||||||
|
# Generate from a specific tag
|
||||||
|
quicommit changelog --from v0.9.0
|
||||||
|
|
||||||
# AI-generate changelog
|
# AI-generate changelog
|
||||||
quicommit changelog --generate
|
quicommit changelog --generate
|
||||||
@@ -222,7 +234,7 @@ quicommit profile token
|
|||||||
```bash
|
```bash
|
||||||
# Configure Ollama (local)
|
# Configure Ollama (local)
|
||||||
quicommit config set-llm ollama
|
quicommit config set-llm ollama
|
||||||
quicommit config set-llm ollama --url http://localhost:11434 --model llama2
|
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
|
||||||
|
|
||||||
# Configure OpenAI
|
# Configure OpenAI
|
||||||
quicommit config set-llm openai
|
quicommit config set-llm openai
|
||||||
@@ -309,13 +321,14 @@ quicommit config reset --force
|
|||||||
| `-b, --breaking` | Mark as breaking change |
|
| `-b, --breaking` | Mark as breaking change |
|
||||||
| `-d, --date` | Use date-based commit message |
|
| `-d, --date` | Use date-based commit message |
|
||||||
| `--manual` | Manual input, skip AI |
|
| `--manual` | Manual input, skip AI |
|
||||||
| `-a, --all` | Stage all changes |
|
| `-a, --all` | Stage all changes (skips `.gitignore`-matched files) |
|
||||||
| `-S, --sign` | GPG sign commit |
|
| `-S, --sign` | GPG sign commit |
|
||||||
| `--amend` | Amend previous commit |
|
| `--amend` | Amend previous commit |
|
||||||
| `--dry-run` | Show without committing |
|
| `--dry-run` | Show without committing |
|
||||||
| `--conventional` | Use Conventional Commits format |
|
| `--conventional` | Use Conventional Commits format |
|
||||||
| `--commitlint` | Use commitlint format |
|
| `--commitlint` | Use commitlint format |
|
||||||
| `--no-verify` | Skip commit message verification |
|
| `--no-verify` | Skip commit message verification |
|
||||||
|
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
|
||||||
| `-y, --yes` | Skip confirmation |
|
| `-y, --yes` | Skip confirmation |
|
||||||
| `--push` | Push after committing |
|
| `--push` | Push after committing |
|
||||||
| `--remote` | Specify remote repository (default: origin) |
|
| `--remote` | Specify remote repository (default: origin) |
|
||||||
@@ -326,6 +339,7 @@ quicommit config reset --force
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `-n, --name` | Tag name |
|
| `-n, --name` | Tag name |
|
||||||
| `-b, --bump` | Version bump (major/minor/patch) |
|
| `-b, --bump` | Version bump (major/minor/patch) |
|
||||||
|
| `-A, --auto` | Auto-detect version from project config files (Cargo.toml, package.json, pom.xml, etc.), fall back to commit analysis (conflicts with `--bump`) |
|
||||||
| `-m, --message` | Tag message |
|
| `-m, --message` | Tag message |
|
||||||
| `-g, --generate` | AI-generate message |
|
| `-g, --generate` | AI-generate message |
|
||||||
| `-S, --sign` | GPG sign tag |
|
| `-S, --sign` | GPG sign tag |
|
||||||
@@ -334,6 +348,7 @@ quicommit config reset --force
|
|||||||
| `-p, --push` | Push to remote |
|
| `-p, --push` | Push to remote |
|
||||||
| `-r, --remote` | Specify remote repository (default: origin) |
|
| `-r, --remote` | Specify remote repository (default: origin) |
|
||||||
| `--dry-run` | Dry run |
|
| `--dry-run` | Dry run |
|
||||||
|
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
|
||||||
| `-y, --yes` | Skip confirmation |
|
| `-y, --yes` | Skip confirmation |
|
||||||
|
|
||||||
### Changelog Options
|
### Changelog Options
|
||||||
@@ -341,16 +356,16 @@ quicommit config reset --force
|
|||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `-o, --output` | Output file path |
|
| `-o, --output` | Output file path |
|
||||||
| `-v, --version` | Generate for specific version |
|
| `--version` | Generate for specific version |
|
||||||
| `-f, --from` | Generate from specific tag |
|
| `-f, --from` | Generate from specific tag (auto-detected from existing changelog if omitted) |
|
||||||
| `-t, --to` | Generate to specific ref (default: HEAD) |
|
| `-t, --to` | Generate to specific ref (default: HEAD) |
|
||||||
| `-i, --init` | Initialize new changelog file |
|
| `-i, --init` | Initialize new changelog file |
|
||||||
| `-g, --generate` | AI-generate changelog |
|
| `-g, --generate` | AI-generate changelog |
|
||||||
| `--prepend` | Prepend to existing changelog |
|
|
||||||
| `--include-hashes` | Include commit hashes |
|
| `--include-hashes` | Include commit hashes |
|
||||||
| `--include-authors` | Include authors |
|
| `--include-authors` | Include authors |
|
||||||
| `--format` | Format (keep-a-changelog, github-releases) |
|
| `--format` | Format (keep-a-changelog, github-releases) |
|
||||||
| `--dry-run` | Dry run (output to stdout) |
|
| `--dry-run` | Dry run (output to stdout) |
|
||||||
|
| `--think` | Enable LLM thinking/reasoning mode (overrides config) |
|
||||||
| `-y, --yes` | Skip confirmation |
|
| `-y, --yes` | Skip confirmation |
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
|
|||||||
33
readme_zh.md
33
readme_zh.md
@@ -17,7 +17,7 @@
|
|||||||
- **AI智能生成**:使用LLM API(Ollama本地、OpenAI、Anthropic Claude、Kimi、DeepSeek、OpenRouter)生成提交信息、标签和变更日志
|
- **AI智能生成**:使用LLM API(Ollama本地、OpenAI、Anthropic Claude、Kimi、DeepSeek、OpenRouter)生成提交信息、标签和变更日志
|
||||||
- **规范化提交**:支持Conventional Commits和commitlint格式规范
|
- **规范化提交**:支持Conventional Commits和commitlint格式规范
|
||||||
- **多配置管理**:为不同场景管理多个Git身份,支持SSH密钥和GPG签名配置
|
- **多配置管理**:为不同场景管理多个Git身份,支持SSH密钥和GPG签名配置
|
||||||
- **智能标签管理**:基于语义版本自动检测升级,AI生成标签信息
|
- **智能标签管理**:基于语义版本自动检测升级,AI生成标签信息;支持从 16+ 种项目配置文件(Cargo.toml、package.json、pom.xml、pubspec.yaml 等)自动读取版本
|
||||||
- **变更日志生成**:自动生成Keep a Changelog格式的变更日志
|
- **变更日志生成**:自动生成Keep a Changelog格式的变更日志
|
||||||
- **安全保护**:使用系统密钥环进行安全存储
|
- **安全保护**:使用系统密钥环进行安全存储
|
||||||
- **交互式界面**:美观的CLI界面,支持预览和确认
|
- **交互式界面**:美观的CLI界面,支持预览和确认
|
||||||
@@ -62,7 +62,7 @@ quicommit commit
|
|||||||
# 手动提交
|
# 手动提交
|
||||||
quicommit commit --manual -t feat -m "添加新功能"
|
quicommit commit --manual -t feat -m "添加新功能"
|
||||||
|
|
||||||
# 暂存所有文件并提交
|
# 暂存所有文件并提交(自动跳过 .gitignore 匹配的文件)
|
||||||
quicommit commit -a
|
quicommit commit -a
|
||||||
|
|
||||||
# 跳过确认直接提交
|
# 跳过确认直接提交
|
||||||
@@ -71,16 +71,25 @@ quicommit commit --yes
|
|||||||
# 使用日期格式的提交信息
|
# 使用日期格式的提交信息
|
||||||
quicommit commit --date
|
quicommit commit --date
|
||||||
|
|
||||||
|
# 为本次提交启用 LLM 思考/推理模式
|
||||||
|
quicommit commit --think
|
||||||
|
|
||||||
# 提交后推送到远程
|
# 提交后推送到远程
|
||||||
quicommit commit --push
|
quicommit commit --push
|
||||||
```
|
```
|
||||||
|
|
||||||
|
在暂存更改时(自动暂存或 `--all` 模式),会自动跳过匹配 `.gitignore` 规则的文件,并清理索引中已被忽略的已跟踪文件,最后打印出被跳过的文件列表。
|
||||||
|
|
||||||
### 创建标签
|
### 创建标签
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 自动检测版本升级
|
# 自动检测版本升级
|
||||||
quicommit tag
|
quicommit tag
|
||||||
|
|
||||||
|
# 从项目配置文件(Cargo.toml、package.json、pom.xml 等)自动检测版本,
|
||||||
|
# 回退到基于 commit 的分析
|
||||||
|
quicommit tag --auto
|
||||||
|
|
||||||
# 指定版本升级类型
|
# 指定版本升级类型
|
||||||
quicommit tag --bump minor
|
quicommit tag --bump minor
|
||||||
|
|
||||||
@@ -97,11 +106,14 @@ quicommit tag --push
|
|||||||
### 生成变更日志
|
### 生成变更日志
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 生成未发布变更的变更日志
|
# 生成未发布变更的变更日志(未指定 --from 时自动从现有 changelog 的最高版本检测)
|
||||||
quicommit changelog
|
quicommit changelog
|
||||||
|
|
||||||
# 为特定版本生成
|
# 为特定版本生成
|
||||||
quicommit changelog -v 1.0.0
|
quicommit changelog --version 1.0.0
|
||||||
|
|
||||||
|
# 从指定标签生成
|
||||||
|
quicommit changelog --from v0.9.0
|
||||||
|
|
||||||
# AI生成变更日志
|
# AI生成变更日志
|
||||||
quicommit changelog --generate
|
quicommit changelog --generate
|
||||||
@@ -216,7 +228,7 @@ quicommit profile token
|
|||||||
```bash
|
```bash
|
||||||
# 配置Ollama(本地)
|
# 配置Ollama(本地)
|
||||||
quicommit config set-llm ollama
|
quicommit config set-llm ollama
|
||||||
quicommit config set-llm ollama --url http://localhost:11434 --model llama2
|
quicommit config set-llm ollama --base-url http://localhost:11434 --model llama2
|
||||||
|
|
||||||
# 配置OpenAI
|
# 配置OpenAI
|
||||||
quicommit config set-llm openai
|
quicommit config set-llm openai
|
||||||
@@ -303,13 +315,14 @@ quicommit config reset --force
|
|||||||
| `-b, --breaking` | 标记为破坏性变更 |
|
| `-b, --breaking` | 标记为破坏性变更 |
|
||||||
| `-d, --date` | 使用日期格式的提交信息 |
|
| `-d, --date` | 使用日期格式的提交信息 |
|
||||||
| `--manual` | 手动输入,跳过AI生成 |
|
| `--manual` | 手动输入,跳过AI生成 |
|
||||||
| `-a, --all` | 暂存所有更改 |
|
| `-a, --all` | 暂存所有更改(自动跳过 `.gitignore` 匹配的文件) |
|
||||||
| `-S, --sign` | GPG签名提交 |
|
| `-S, --sign` | GPG签名提交 |
|
||||||
| `--amend` | 修改上一次提交 |
|
| `--amend` | 修改上一次提交 |
|
||||||
| `--dry-run` | 试运行,不实际提交 |
|
| `--dry-run` | 试运行,不实际提交 |
|
||||||
| `--conventional` | 使用Conventional Commits格式 |
|
| `--conventional` | 使用Conventional Commits格式 |
|
||||||
| `--commitlint` | 使用commitlint格式 |
|
| `--commitlint` | 使用commitlint格式 |
|
||||||
| `--no-verify` | 不验证提交信息 |
|
| `--no-verify` | 不验证提交信息 |
|
||||||
|
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||||
| `-y, --yes` | 跳过确认提示 |
|
| `-y, --yes` | 跳过确认提示 |
|
||||||
| `--push` | 提交后推送到远程 |
|
| `--push` | 提交后推送到远程 |
|
||||||
| `--remote` | 指定远程仓库(默认:origin) |
|
| `--remote` | 指定远程仓库(默认:origin) |
|
||||||
@@ -320,6 +333,7 @@ quicommit config reset --force
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `-n, --name` | 标签名称 |
|
| `-n, --name` | 标签名称 |
|
||||||
| `-b, --bump` | 版本升级类型(major/minor/patch) |
|
| `-b, --bump` | 版本升级类型(major/minor/patch) |
|
||||||
|
| `-A, --auto` | 从项目配置文件(Cargo.toml、package.json、pom.xml 等 16+ 种)自动检测版本,回退到基于 commit 的分析(与 `--bump` 互斥) |
|
||||||
| `-m, --message` | 标签信息 |
|
| `-m, --message` | 标签信息 |
|
||||||
| `-g, --generate` | AI生成标签信息 |
|
| `-g, --generate` | AI生成标签信息 |
|
||||||
| `-S, --sign` | GPG签名标签 |
|
| `-S, --sign` | GPG签名标签 |
|
||||||
@@ -328,6 +342,7 @@ quicommit config reset --force
|
|||||||
| `-p, --push` | 推送到远程 |
|
| `-p, --push` | 推送到远程 |
|
||||||
| `-r, --remote` | 指定远程仓库(默认:origin) |
|
| `-r, --remote` | 指定远程仓库(默认:origin) |
|
||||||
| `--dry-run` | 试运行 |
|
| `--dry-run` | 试运行 |
|
||||||
|
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||||
| `-y, --yes` | 跳过确认提示 |
|
| `-y, --yes` | 跳过确认提示 |
|
||||||
|
|
||||||
### changelog命令选项
|
### changelog命令选项
|
||||||
@@ -335,16 +350,16 @@ quicommit config reset --force
|
|||||||
| 选项 | 说明 |
|
| 选项 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `-o, --output` | 输出文件路径 |
|
| `-o, --output` | 输出文件路径 |
|
||||||
| `-v, --version` | 为特定版本生成 |
|
| `--version` | 为特定版本生成 |
|
||||||
| `-f, --from` | 从指定标签生成 |
|
| `-f, --from` | 从指定标签生成(未指定时自动从现有 changelog 的最高版本检测) |
|
||||||
| `-t, --to` | 生成到指定引用(默认:HEAD) |
|
| `-t, --to` | 生成到指定引用(默认:HEAD) |
|
||||||
| `-i, --init` | 初始化新的变更日志文件 |
|
| `-i, --init` | 初始化新的变更日志文件 |
|
||||||
| `-g, --generate` | AI生成变更日志 |
|
| `-g, --generate` | AI生成变更日志 |
|
||||||
| `--prepend` | 添加到现有变更日志开头 |
|
|
||||||
| `--include-hashes` | 包含提交哈希 |
|
| `--include-hashes` | 包含提交哈希 |
|
||||||
| `--include-authors` | 包含作者信息 |
|
| `--include-authors` | 包含作者信息 |
|
||||||
| `--format` | 格式(keep-a-changelog、github-releases) |
|
| `--format` | 格式(keep-a-changelog、github-releases) |
|
||||||
| `--dry-run` | 试运行(输出到stdout) |
|
| `--dry-run` | 试运行(输出到stdout) |
|
||||||
|
| `--think` | 启用 LLM 思考/推理模式(覆盖配置) |
|
||||||
| `-y, --yes` | 跳过确认提示 |
|
| `-y, --yes` | 跳过确认提示 |
|
||||||
|
|
||||||
## 配置文件
|
## 配置文件
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use crate::config::{Language, manager::ConfigManager};
|
use crate::config::{Language, manager::ConfigManager};
|
||||||
use crate::generator::ContentGenerator;
|
use crate::generator::ContentGenerator;
|
||||||
|
use crate::git::GitRepo;
|
||||||
use crate::git::find_repo;
|
use crate::git::find_repo;
|
||||||
use crate::git::{CommitInfo, changelog::*};
|
use crate::git::{CommitInfo, changelog::*};
|
||||||
use crate::i18n::{Messages, translate_changelog_category};
|
use crate::i18n::{Messages, translate_changelog_category};
|
||||||
@@ -120,7 +121,10 @@ impl ChangelogCommand {
|
|||||||
|
|
||||||
// Get commits
|
// Get commits
|
||||||
println!("{}", messages.fetching_commits());
|
println!("{}", messages.fetching_commits());
|
||||||
let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?;
|
|
||||||
|
// Determine from_tag: use explicit --from, or auto-detect from changelog
|
||||||
|
let from_tag = self.resolve_from_tag(&repo, &output_path, &messages);
|
||||||
|
let commits = generate_from_history(&repo, from_tag.as_deref(), Some(&self.to))?;
|
||||||
|
|
||||||
if commits.is_empty() {
|
if commits.is_empty() {
|
||||||
bail!("{}", messages.no_commits_found());
|
bail!("{}", messages.no_commits_found());
|
||||||
@@ -167,33 +171,13 @@ impl ChangelogCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to file (always prepend to preserve history)
|
// Write to file (always prepend new entry before existing versions)
|
||||||
if output_path.exists() {
|
if output_path.exists() {
|
||||||
let existing = std::fs::read_to_string(&output_path)?;
|
let existing = std::fs::read_to_string(&output_path)?;
|
||||||
let new_content = if existing.is_empty() {
|
let new_content = if existing.is_empty() {
|
||||||
format!("{}{}", CHANGELOG_HEADER, changelog)
|
format!("{}{}", CHANGELOG_HEADER, changelog)
|
||||||
} else if existing.starts_with(CHANGELOG_HEADER) {
|
|
||||||
format!("{}{}", CHANGELOG_HEADER, changelog)
|
|
||||||
} else if existing.starts_with("# Changelog") {
|
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
format!("{}{}", CHANGELOG_HEADER, changelog)
|
insert_changelog_entry(&existing, &changelog)
|
||||||
};
|
};
|
||||||
std::fs::write(&output_path, new_content)?;
|
std::fs::write(&output_path, new_content)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -206,6 +190,28 @@ impl ChangelogCommand {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_from_tag(
|
||||||
|
&self,
|
||||||
|
repo: &GitRepo,
|
||||||
|
output_path: &PathBuf,
|
||||||
|
messages: &Messages,
|
||||||
|
) -> Option<String> {
|
||||||
|
// Explicit --from always wins
|
||||||
|
if self.from.is_some() {
|
||||||
|
return self.from.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect: find highest version already in changelog
|
||||||
|
let existing = std::fs::read_to_string(output_path).ok()?;
|
||||||
|
let versions = parse_changelog_versions(&existing);
|
||||||
|
let highest = versions.first()?;
|
||||||
|
|
||||||
|
// Match highest version to a git tag
|
||||||
|
let tag = repo.find_tag_by_version(highest)?;
|
||||||
|
println!(" {}: {}", messages.version(), tag.name);
|
||||||
|
Some(tag.name)
|
||||||
|
}
|
||||||
|
|
||||||
async fn generate_with_ai(
|
async fn generate_with_ai(
|
||||||
&self,
|
&self,
|
||||||
version: &str,
|
version: &str,
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use std::path::PathBuf;
|
|||||||
use crate::config::{Language, manager::ConfigManager};
|
use crate::config::{Language, manager::ConfigManager};
|
||||||
use crate::generator::ContentGenerator;
|
use crate::generator::ContentGenerator;
|
||||||
use crate::git::tag::{
|
use crate::git::tag::{
|
||||||
TagBuilder, VersionBump, bump_version, get_latest_version, suggest_version_bump,
|
ConfigVersion, TagBuilder, VersionBump, bump_version, get_latest_version,
|
||||||
|
read_project_versions, suggest_version_bump,
|
||||||
};
|
};
|
||||||
use crate::git::{GitRepo, find_repo};
|
use crate::git::{GitRepo, find_repo};
|
||||||
use crate::i18n::Messages;
|
use crate::i18n::Messages;
|
||||||
@@ -63,6 +64,11 @@ pub struct TagCommand {
|
|||||||
/// Skip interactive prompts
|
/// Skip interactive prompts
|
||||||
#[arg(short = 'y', long)]
|
#[arg(short = 'y', long)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
|
|
||||||
|
/// Auto-detect version from project config (Cargo.toml/pyproject.toml),
|
||||||
|
/// falling back to commit analysis with confirmation. Mutually exclusive with --bump.
|
||||||
|
#[arg(short = 'A', long, conflicts_with = "bump")]
|
||||||
|
auto: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TagCommand {
|
impl TagCommand {
|
||||||
@@ -80,6 +86,9 @@ impl TagCommand {
|
|||||||
// Determine tag name
|
// Determine tag name
|
||||||
let tag_name = if let Some(name) = &self.name {
|
let tag_name = if let Some(name) = &self.name {
|
||||||
name.clone()
|
name.clone()
|
||||||
|
} else if self.auto {
|
||||||
|
self.auto_detect_version(&repo, &config.tag.version_prefix, &messages)
|
||||||
|
.await?
|
||||||
} else if let Some(bump_str) = &self.bump {
|
} else if let Some(bump_str) = &self.bump {
|
||||||
// Calculate bumped version
|
// Calculate bumped version
|
||||||
let prefix = &config.tag.version_prefix;
|
let prefix = &config.tag.version_prefix;
|
||||||
@@ -325,6 +334,89 @@ impl TagCommand {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn auto_detect_version(
|
||||||
|
&self,
|
||||||
|
repo: &GitRepo,
|
||||||
|
prefix: &str,
|
||||||
|
messages: &Messages,
|
||||||
|
) -> Result<String> {
|
||||||
|
let project_dir = std::env::current_dir()?;
|
||||||
|
|
||||||
|
// 1. Try reading from project config files
|
||||||
|
let config_versions = read_project_versions(&project_dir);
|
||||||
|
|
||||||
|
if !config_versions.is_empty() {
|
||||||
|
if config_versions.len() == 1 {
|
||||||
|
let cv = &config_versions[0];
|
||||||
|
let tag_name = format!("{}{}", prefix, cv.version);
|
||||||
|
println!(
|
||||||
|
"{} ({}): {}",
|
||||||
|
messages.found_version_in(),
|
||||||
|
cv.source,
|
||||||
|
tag_name.cyan()
|
||||||
|
);
|
||||||
|
return Ok(tag_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple config files: let user choose
|
||||||
|
return self.select_config_version(&config_versions, prefix, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to commit analysis
|
||||||
|
println!("{}", messages.auto_detect_bump());
|
||||||
|
let commits = repo.get_commits(50)?;
|
||||||
|
let bump = suggest_version_bump(&commits);
|
||||||
|
let latest = get_latest_version(repo, prefix)?.unwrap_or_else(|| Version::new(0, 0, 0));
|
||||||
|
let version = bump_version(&latest, bump, None);
|
||||||
|
let tag_name = format!("{}{}", prefix, version);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{} {:?} → {}",
|
||||||
|
messages.suggested_bump(),
|
||||||
|
bump,
|
||||||
|
tag_name.cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
if !self.yes {
|
||||||
|
let confirm = Confirm::new()
|
||||||
|
.with_prompt(messages.use_this_version())
|
||||||
|
.default(true)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
if !confirm {
|
||||||
|
// Fall through to interactive version selection
|
||||||
|
return self
|
||||||
|
.select_version_interactive(repo, prefix, messages)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tag_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_config_version(
|
||||||
|
&self,
|
||||||
|
config_versions: &[ConfigVersion],
|
||||||
|
prefix: &str,
|
||||||
|
messages: &Messages,
|
||||||
|
) -> Result<String> {
|
||||||
|
println!("\n{}", messages.found_multiple_versions().bold());
|
||||||
|
|
||||||
|
let items: Vec<String> = config_versions
|
||||||
|
.iter()
|
||||||
|
.map(|cv| format!("{} → {}{}", cv.source, prefix, cv.version))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let selection = Select::new()
|
||||||
|
.with_prompt(messages.select_version_to_use())
|
||||||
|
.items(&items)
|
||||||
|
.default(0)
|
||||||
|
.interact()?;
|
||||||
|
|
||||||
|
let cv = &config_versions[selection];
|
||||||
|
Ok(format!("{}{}", prefix, cv.version))
|
||||||
|
}
|
||||||
|
|
||||||
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
|
fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result<String> {
|
||||||
let default_msg = format!("Release {}", version);
|
let default_msg = format!("Release {}", version);
|
||||||
|
|
||||||
|
|||||||
@@ -409,8 +409,8 @@ pub fn generate_from_history(
|
|||||||
if let Some(from) = from_tag {
|
if let Some(from) = from_tag {
|
||||||
repo.get_commits_between(from, to_ref)
|
repo.get_commits_between(from, to_ref)
|
||||||
} else {
|
} else {
|
||||||
// Get last 50 commits if no tag specified
|
// Get all commits from the beginning (no from_tag = initial changelog)
|
||||||
repo.get_commits(50)
|
repo.get_commits(usize::MAX)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,3 +476,137 @@ impl ChangelogEntry {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract version strings from Keep a Changelog content.
|
||||||
|
/// Returns versions sorted by semver descending (highest first).
|
||||||
|
/// Excludes "Unreleased".
|
||||||
|
pub fn parse_changelog_versions(content: &str) -> Vec<String> {
|
||||||
|
use semver::Version;
|
||||||
|
|
||||||
|
let mut versions: Vec<String> = vec![];
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with("## [") {
|
||||||
|
if let Some(end) = trimmed.find(']') {
|
||||||
|
let version = &trimmed[4..end];
|
||||||
|
if version != "Unreleased" && !version.is_empty() {
|
||||||
|
versions.push(version.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort by semver descending so highest version is first
|
||||||
|
versions.sort_by(|a, b| {
|
||||||
|
match (Version::parse(a), Version::parse(b)) {
|
||||||
|
(Ok(va), Ok(vb)) => vb.cmp(&va),
|
||||||
|
(Ok(_), Err(_)) => std::cmp::Ordering::Less,
|
||||||
|
(Err(_), Ok(_)) => std::cmp::Ordering::Greater,
|
||||||
|
(Err(_), Err(_)) => std::cmp::Ordering::Equal,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
versions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a new changelog entry into existing changelog content.
|
||||||
|
/// The new entry is placed after the header and before the first existing version section.
|
||||||
|
/// If no existing version sections are found, the new entry is appended after the header.
|
||||||
|
pub fn insert_changelog_entry(existing: &str, new_entry: &str) -> String {
|
||||||
|
// Find the first version section (## [x.y.z])
|
||||||
|
if let Some(first_ver_pos) = existing.find("\n## [") {
|
||||||
|
let (header, rest) = existing.split_at(first_ver_pos);
|
||||||
|
// Ensure new_entry ends with a blank line before the next section
|
||||||
|
let entry = new_entry.trim_end();
|
||||||
|
format!("{}\n{}\n{}", header.trim_end(), entry, rest)
|
||||||
|
} else {
|
||||||
|
// No existing version sections — append after header
|
||||||
|
format!("{}\n{}", existing.trim_end(), new_entry.trim_end())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_changelog_versions_extracts_versions() {
|
||||||
|
let content = "# Changelog\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
||||||
|
let versions = parse_changelog_versions(content);
|
||||||
|
assert_eq!(versions, vec!["0.2.0", "0.1.0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_changelog_versions_sorts_highest_first() {
|
||||||
|
// Versions in reversed order — should still return highest first
|
||||||
|
let content = "# Changelog\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n\n## [0.3.0] - 2024-04-01\n### Added\n- feature C\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
|
||||||
|
let versions = parse_changelog_versions(content);
|
||||||
|
assert_eq!(versions, vec!["0.3.0", "0.2.0", "0.1.0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_changelog_versions_excludes_unreleased() {
|
||||||
|
let content = "# Changelog\n\n## [Unreleased]\n### Added\n- wip\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
||||||
|
let versions = parse_changelog_versions(content);
|
||||||
|
assert_eq!(versions, vec!["0.1.0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_changelog_versions_empty_content() {
|
||||||
|
let versions = parse_changelog_versions("");
|
||||||
|
assert!(versions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_changelog_versions_no_versions() {
|
||||||
|
let content = "# Changelog\n\nSome description text.\n";
|
||||||
|
let versions = parse_changelog_versions(content);
|
||||||
|
assert!(versions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_changelog_entry_between_header_and_existing() {
|
||||||
|
let existing = "# Changelog\n\nAll notable changes...\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
||||||
|
let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
|
||||||
|
let result = insert_changelog_entry(existing, new_entry);
|
||||||
|
|
||||||
|
// New entry should appear after header, before 0.1.0
|
||||||
|
assert!(result.contains("## [0.2.0]"));
|
||||||
|
assert!(result.contains("## [0.1.0]"));
|
||||||
|
let pos_new = result.find("## [0.2.0]").unwrap();
|
||||||
|
let pos_old = result.find("## [0.1.0]").unwrap();
|
||||||
|
assert!(pos_new < pos_old, "new version should be before old version");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_changelog_entry_preserves_all_existing_content() {
|
||||||
|
let existing = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2024-03-01\n### Added\n- feature B\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
||||||
|
let new_entry = "## [0.3.0] - 2024-04-01\n### Added\n- feature C\n";
|
||||||
|
let result = insert_changelog_entry(existing, new_entry);
|
||||||
|
|
||||||
|
assert!(result.contains("## [0.3.0]"));
|
||||||
|
assert!(result.contains("## [0.2.0]"));
|
||||||
|
assert!(result.contains("## [0.1.0]"));
|
||||||
|
assert!(result.contains("feature A"), "oldest content preserved");
|
||||||
|
assert!(result.contains("feature B"), "middle content preserved");
|
||||||
|
assert!(result.contains("feature C"), "new content present");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_changelog_entry_empty_existing() {
|
||||||
|
let existing = "# Changelog\n\nAll notable changes...\n\n";
|
||||||
|
let new_entry = "## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
||||||
|
let result = insert_changelog_entry(existing, new_entry);
|
||||||
|
assert!(result.contains("## [0.1.0]"));
|
||||||
|
assert!(result.contains("# Changelog"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_insert_changelog_entry_extra_blank_lines_in_header() {
|
||||||
|
let existing = "# Changelog\n\n\n\n## [0.1.0] - 2024-02-01\n### Added\n- feature A\n";
|
||||||
|
let new_entry = "## [0.2.0] - 2024-03-01\n### Added\n- feature B\n";
|
||||||
|
let result = insert_changelog_entry(existing, new_entry);
|
||||||
|
|
||||||
|
let pos_new = result.find("## [0.2.0]").unwrap();
|
||||||
|
let pos_old = result.find("## [0.1.0]").unwrap();
|
||||||
|
assert!(pos_new < pos_old, "new version should be before old version");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
158
src/git/mod.rs
158
src/git/mod.rs
@@ -862,24 +862,34 @@ impl GitRepo {
|
|||||||
let name = String::from_utf8_lossy(name);
|
let name = String::from_utf8_lossy(name);
|
||||||
let name = name.strip_prefix("refs/tags/").unwrap_or(&name);
|
let name = name.strip_prefix("refs/tags/").unwrap_or(&name);
|
||||||
|
|
||||||
if let Ok(commit) = self.repo.find_commit(oid) {
|
// Use find_object + peel_to_commit to handle both lightweight
|
||||||
tags.push(TagInfo {
|
// (oid is a commit) and annotated (oid is a tag object) tags.
|
||||||
name: name.to_string(),
|
if let Ok(obj) = self.repo.find_object(oid, None) {
|
||||||
target: oid.to_string(),
|
if let Ok(commit) = obj.peel_to_commit() {
|
||||||
message: commit.message().unwrap_or("").to_string(),
|
tags.push(TagInfo {
|
||||||
time: commit.time().seconds(),
|
name: name.to_string(),
|
||||||
});
|
target: commit.id().to_string(),
|
||||||
|
message: commit.message().unwrap_or("").to_string(),
|
||||||
|
time: commit.time().seconds(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Sort tags by time (newest first)
|
// Sort tags by semver (descending), then by time
|
||||||
tags.sort_by(|a, b| b.time.cmp(&a.time));
|
crate::git::tag::sort_tags_by_semver(&mut tags);
|
||||||
|
|
||||||
Ok(tags)
|
Ok(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find a tag whose version (stripping 'v' prefix) matches the given version string.
|
||||||
|
pub fn find_tag_by_version(&self, version: &str) -> Option<TagInfo> {
|
||||||
|
let tags = self.get_tags().ok()?;
|
||||||
|
tags.into_iter().find(|t| t.version_name() == version)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a tag
|
/// Create a tag
|
||||||
pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> {
|
pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> {
|
||||||
let head = self.repo.head()?;
|
let head = self.repo.head()?;
|
||||||
@@ -1071,6 +1081,13 @@ pub struct TagInfo {
|
|||||||
pub time: i64,
|
pub time: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl TagInfo {
|
||||||
|
/// Return the version portion of the tag name (stripping leading 'v' if present).
|
||||||
|
pub fn version_name(&self) -> &str {
|
||||||
|
self.name.strip_prefix('v').unwrap_or(&self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Repository status summary
|
/// Repository status summary
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StatusSummary {
|
pub struct StatusSummary {
|
||||||
@@ -1441,3 +1458,126 @@ pub struct ConfigDiff {
|
|||||||
pub left: String,
|
pub left: String,
|
||||||
pub right: String,
|
pub right: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use git2::Signature;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn init_test_repo() -> (TempDir, GitRepo) {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||||
|
|
||||||
|
// Configure git user
|
||||||
|
let mut config = repo.config().unwrap();
|
||||||
|
config.set_str("user.name", "Test User").unwrap();
|
||||||
|
config.set_str("user.email", "test@example.com").unwrap();
|
||||||
|
|
||||||
|
// Create an initial commit (needed for tags)
|
||||||
|
let sig = Signature::now("Test User", "test@example.com").unwrap();
|
||||||
|
let tree_id = {
|
||||||
|
let mut index = repo.index().unwrap();
|
||||||
|
index.write_tree().unwrap()
|
||||||
|
};
|
||||||
|
let tree = repo.find_tree(tree_id).unwrap();
|
||||||
|
repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let git_repo = GitRepo::open(dir.path()).unwrap();
|
||||||
|
(dir, git_repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_tags_returns_annotated_tags() {
|
||||||
|
let (_dir, repo) = init_test_repo();
|
||||||
|
let sig = Signature::now("Test User", "test@example.com").unwrap();
|
||||||
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
||||||
|
|
||||||
|
// Create an annotated tag (what QuiCommit creates by default)
|
||||||
|
repo.repo
|
||||||
|
.tag("v0.2.0", head.as_object(), &sig, "Release v0.2.0", false)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tags = repo.get_tags().unwrap();
|
||||||
|
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
tag_names.contains(&"v0.2.0"),
|
||||||
|
"annotated tag v0.2.0 should appear in tags list, got: {:?}",
|
||||||
|
tag_names
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_tags_returns_lightweight_tags() {
|
||||||
|
let (_dir, repo) = init_test_repo();
|
||||||
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
||||||
|
|
||||||
|
// Create a lightweight tag
|
||||||
|
repo.repo.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tags = repo.get_tags().unwrap();
|
||||||
|
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
tag_names.contains(&"v0.1.0"),
|
||||||
|
"lightweight tag v0.1.0 should appear in tags list, got: {:?}",
|
||||||
|
tag_names
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_tags_returns_mixed_annotated_and_lightweight() {
|
||||||
|
let (_dir, repo) = init_test_repo();
|
||||||
|
let sig = Signature::now("Test User", "test@example.com").unwrap();
|
||||||
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
||||||
|
|
||||||
|
// Create annotated tag
|
||||||
|
repo.repo
|
||||||
|
.tag("v0.2.0", head.as_object(), &sig, "annotated", false)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create lightweight tag
|
||||||
|
repo.repo
|
||||||
|
.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tags = repo.get_tags().unwrap();
|
||||||
|
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
tag_names.contains(&"v0.2.0"),
|
||||||
|
"annotated tag should be present, got: {:?}",
|
||||||
|
tag_names
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tag_names.contains(&"v0.1.0"),
|
||||||
|
"lightweight tag should be present, got: {:?}",
|
||||||
|
tag_names
|
||||||
|
);
|
||||||
|
assert_eq!(tags.len(), 2, "should have exactly 2 tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_tags_target_points_to_commit_not_tag_object() {
|
||||||
|
let (_dir, repo) = init_test_repo();
|
||||||
|
let sig = Signature::now("Test User", "test@example.com").unwrap();
|
||||||
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
||||||
|
let expected_commit_id = head.id().to_string();
|
||||||
|
|
||||||
|
// Create annotated tag
|
||||||
|
repo.repo
|
||||||
|
.tag("v0.2.0", head.as_object(), &sig, "annotated", false)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tags = repo.get_tags().unwrap();
|
||||||
|
let tag = tags.iter().find(|t| t.name == "v0.2.0").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tag.target, expected_commit_id,
|
||||||
|
"tag.target should be the commit OID, not the tag object OID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
454
src/git/tag.rs
454
src/git/tag.rs
@@ -1,6 +1,7 @@
|
|||||||
use super::GitRepo;
|
use super::GitRepo;
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use semver::Version;
|
use semver::Version;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
/// Tag builder for creating tags
|
/// Tag builder for creating tags
|
||||||
pub struct TagBuilder {
|
pub struct TagBuilder {
|
||||||
@@ -327,3 +328,456 @@ pub fn list_tags(
|
|||||||
Ok(filtered)
|
Ok(filtered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sort tags by semver version descending, then by time descending.
|
||||||
|
/// Non-semver tags are placed after semver tags, sorted by time descending.
|
||||||
|
pub fn sort_tags_by_semver(tags: &mut [super::TagInfo]) {
|
||||||
|
use semver::Version;
|
||||||
|
|
||||||
|
tags.sort_by(|a, b| {
|
||||||
|
match (
|
||||||
|
Version::parse(a.version_name()),
|
||||||
|
Version::parse(b.version_name()),
|
||||||
|
) {
|
||||||
|
(Ok(va), Ok(vb)) => {
|
||||||
|
// Both semver: version descending, then time descending as tiebreaker
|
||||||
|
vb.cmp(&va).then_with(|| b.time.cmp(&a.time))
|
||||||
|
}
|
||||||
|
(Ok(_), Err(_)) => std::cmp::Ordering::Less, // semver tags first
|
||||||
|
(Err(_), Ok(_)) => std::cmp::Ordering::Greater, // semver tags first
|
||||||
|
(Err(_), Err(_)) => b.time.cmp(&a.time), // both non-semver: time descending
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A version found in a project config file, tagged with its source.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConfigVersion {
|
||||||
|
/// Source file name, e.g. "Cargo.toml", "package.json"
|
||||||
|
pub source: String,
|
||||||
|
/// Parsed semantic version
|
||||||
|
pub version: Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read project versions from all supported config files in the project root.
|
||||||
|
///
|
||||||
|
/// If `go.mod` exists the function returns an empty `Vec` immediately — Go projects
|
||||||
|
/// use git tags as the version authority and store nothing in go.mod.
|
||||||
|
///
|
||||||
|
/// Otherwise every supported format is tried. Results are returned in a stable
|
||||||
|
/// order (see `PARSER_ORDER`). Files that don't exist or whose version can't be
|
||||||
|
/// parsed are silently skipped.
|
||||||
|
pub fn read_project_versions(project_dir: &Path) -> Vec<ConfigVersion> {
|
||||||
|
// Go projects: go.mod has no version field; skip directly to commit analysis
|
||||||
|
if project_dir.join("go.mod").exists() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsers: &[(&str, fn(&Path) -> Option<Version>)] = PARSER_ORDER;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
for &(source, parser) in parsers {
|
||||||
|
if let Some(v) = parser(project_dir) {
|
||||||
|
results.push(ConfigVersion {
|
||||||
|
source: source.to_string(),
|
||||||
|
version: v,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable order for parser dispatch. No priority implied — all results are
|
||||||
|
/// surfaced to the user when more than one is found.
|
||||||
|
type Parser = fn(&Path) -> Option<Version>;
|
||||||
|
const PARSER_ORDER: &[(&str, Parser)] = &[
|
||||||
|
("Cargo.toml", try_cargo_toml),
|
||||||
|
("package.json", try_package_json),
|
||||||
|
("pyproject.toml", try_pyproject_toml),
|
||||||
|
("pom.xml", try_pom_xml),
|
||||||
|
("pubspec.yaml", try_pubspec_yaml),
|
||||||
|
("build.sbt", try_build_sbt),
|
||||||
|
("project.clj", try_project_clj),
|
||||||
|
("mix.exs", try_mix_exs),
|
||||||
|
("package.yaml", try_package_yaml),
|
||||||
|
("Project.toml", try_project_toml_julia),
|
||||||
|
("composer.json", try_composer_json),
|
||||||
|
(".csproj", try_csproj),
|
||||||
|
("CMakeLists.txt", try_cmake_lists),
|
||||||
|
("meson.build", try_meson_build),
|
||||||
|
(".gemspec", try_gemspec),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Structured parsers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn try_cargo_toml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
|
||||||
|
let value = content.parse::<toml::Value>().ok()?;
|
||||||
|
let version_str = value.get("package")?.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_pyproject_toml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("pyproject.toml")).ok()?;
|
||||||
|
let value = content.parse::<toml::Value>().ok()?;
|
||||||
|
let version_str = value.get("project")?.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_project_toml_julia(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("Project.toml")).ok()?;
|
||||||
|
let value = content.parse::<toml::Value>().ok()?;
|
||||||
|
let version_str = value.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_package_json(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("package.json")).ok()?;
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||||
|
let version_str = value.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_composer_json(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("composer.json")).ok()?;
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||||
|
let version_str = value.get("version")?.as_str()?;
|
||||||
|
Version::parse(version_str).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_pom_xml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
|
||||||
|
let doc = roxmltree::Document::parse(&content).ok()?;
|
||||||
|
let project = doc.root_element();
|
||||||
|
// Read <version> that is a direct child of <project>, not inside <parent>
|
||||||
|
for child in project.children() {
|
||||||
|
if child.is_element() && child.tag_name().name() == "version" {
|
||||||
|
if let Some(t) = child.text() {
|
||||||
|
return Version::parse(t.trim()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_csproj(dir: &Path) -> Option<Version> {
|
||||||
|
// Find any .csproj file in the root (take the first)
|
||||||
|
let csproj = std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.find(|p| p.extension().map_or(false, |ext| ext == "csproj"))?;
|
||||||
|
let content = std::fs::read_to_string(&csproj).ok()?;
|
||||||
|
let doc = roxmltree::Document::parse(&content).ok()?;
|
||||||
|
// Look for <Version> (preferred) or <VersionPrefix> anywhere in the document
|
||||||
|
for node in doc.descendants() {
|
||||||
|
if node.is_element() {
|
||||||
|
match node.tag_name().name() {
|
||||||
|
"Version" => {
|
||||||
|
if let Some(t) = node.text() {
|
||||||
|
return Version::parse(t.trim()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"VersionPrefix" => {
|
||||||
|
if let Some(t) = node.text() {
|
||||||
|
return Version::parse(t.trim()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Regex-based parsers (code-as-config or simple data formats) ─────
|
||||||
|
|
||||||
|
fn try_pubspec_yaml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("pubspec.yaml")).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r##"(?m)^version\s*:\s*['"]?(\d+\.\d+\.\d+[^'"#\s]*)['"]?"##).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_package_yaml(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("package.yaml")).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r##"(?m)^version\s*:\s*['"]?(\d+\.\d+\.\d+[^'"#\s]*)['"]?"##).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_gemspec(dir: &Path) -> Option<Version> {
|
||||||
|
// A directory may contain multiple .gemspec files; take the first.
|
||||||
|
let gemspec = std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.find(|p| p.extension().map_or(false, |ext| ext == "gemspec"))?;
|
||||||
|
let content = std::fs::read_to_string(&gemspec).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r#"spec\.version\s*=\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_build_sbt(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("build.sbt")).ok()?;
|
||||||
|
let re = regex::Regex::new(r#"version\s*:=\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_project_clj(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("project.clj")).ok()?;
|
||||||
|
let re = regex::Regex::new(r#"\(defproject\s+[^\s]+\s+"(\d+\.\d+\.\d+[^"]*)""#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_mix_exs(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("mix.exs")).ok()?;
|
||||||
|
let re = regex::Regex::new(r#"version\s*:\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_cmake_lists(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("CMakeLists.txt")).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r"project\s*\([^)]*VERSION\s+(\d+\.\d+\.\d+[^\s)]*)").ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_meson_build(dir: &Path) -> Option<Version> {
|
||||||
|
let content = std::fs::read_to_string(dir.join("meson.build")).ok()?;
|
||||||
|
let re =
|
||||||
|
regex::Regex::new(r#"version\s*:\s*["'](\d+\.\d+\.\d+[^"']*)["']"#).ok()?;
|
||||||
|
let caps = re.captures(&content)?;
|
||||||
|
Version::parse(caps.get(1)?.as_str()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::git::TagInfo;
|
||||||
|
|
||||||
|
fn make_tag(name: &str, time: i64) -> TagInfo {
|
||||||
|
TagInfo {
|
||||||
|
name: name.to_string(),
|
||||||
|
target: "abc123".to_string(),
|
||||||
|
message: String::new(),
|
||||||
|
time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_by_semver_descending() {
|
||||||
|
let mut tags = vec![
|
||||||
|
make_tag("v1.0.0", 100),
|
||||||
|
make_tag("v2.0.0", 200),
|
||||||
|
make_tag("v1.5.0", 150),
|
||||||
|
];
|
||||||
|
sort_tags_by_semver(&mut tags);
|
||||||
|
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
|
assert_eq!(names, vec!["v2.0.0", "v1.5.0", "v1.0.0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_semver_ties_broken_by_time() {
|
||||||
|
let mut tags = vec![
|
||||||
|
make_tag("v1.0.0", 100),
|
||||||
|
make_tag("v1.0.0", 300),
|
||||||
|
make_tag("v1.0.0", 200),
|
||||||
|
];
|
||||||
|
sort_tags_by_semver(&mut tags);
|
||||||
|
let times: Vec<i64> = tags.iter().map(|t| t.time).collect();
|
||||||
|
assert_eq!(times, vec![300, 200, 100]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_non_semver_tags_at_end() {
|
||||||
|
let mut tags = vec![
|
||||||
|
make_tag("release-2024", 400),
|
||||||
|
make_tag("v1.0.0", 100),
|
||||||
|
make_tag("staging", 300),
|
||||||
|
make_tag("v0.2.0", 200),
|
||||||
|
];
|
||||||
|
sort_tags_by_semver(&mut tags);
|
||||||
|
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
|
assert_eq!(names[0], "v1.0.0");
|
||||||
|
assert_eq!(names[1], "v0.2.0");
|
||||||
|
assert_eq!(names[2], "release-2024");
|
||||||
|
assert_eq!(names[3], "staging");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_no_prefix_semver() {
|
||||||
|
let mut tags = vec![
|
||||||
|
make_tag("1.0.0", 100),
|
||||||
|
make_tag("2.0.0", 200),
|
||||||
|
make_tag("0.1.0", 50),
|
||||||
|
];
|
||||||
|
sort_tags_by_semver(&mut tags);
|
||||||
|
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
|
assert_eq!(names, vec!["2.0.0", "1.0.0", "0.1.0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_all_non_semver_by_time() {
|
||||||
|
let mut tags = vec![
|
||||||
|
make_tag("release-2024", 100),
|
||||||
|
make_tag("release-2023", 300),
|
||||||
|
make_tag("beta", 200),
|
||||||
|
];
|
||||||
|
sort_tags_by_semver(&mut tags);
|
||||||
|
let times: Vec<i64> = tags.iter().map(|t| t.time).collect();
|
||||||
|
assert_eq!(times, vec![300, 200, 100]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_cargo_toml() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Cargo.toml"),
|
||||||
|
"[package]\nname = \"test\"\nversion = \"0.3.0\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "Cargo.toml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(0, 3, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_pyproject_toml() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pyproject.toml"),
|
||||||
|
"[project]\nname = \"test\"\nversion = \"0.2.1\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "pyproject.toml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(0, 2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_no_config_files() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert!(versions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_multiple_configs_returns_all() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Cargo.toml"),
|
||||||
|
"[package]\nname = \"test\"\nversion = \"0.3.0\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pyproject.toml"),
|
||||||
|
"[project]\nname = \"test\"\nversion = \"0.2.1\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 2);
|
||||||
|
// Order is defined by PARSER_ORDER: Cargo.toml before pyproject.toml
|
||||||
|
assert_eq!(versions[0].source, "Cargo.toml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(0, 3, 0));
|
||||||
|
assert_eq!(versions[1].source, "pyproject.toml");
|
||||||
|
assert_eq!(versions[1].version, Version::new(0, 2, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_package_json() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("package.json"),
|
||||||
|
r#"{"name": "test", "version": "1.2.3"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "package.json");
|
||||||
|
assert_eq!(versions[0].version, Version::new(1, 2, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_pom_xml() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pom.xml"),
|
||||||
|
"<project><modelVersion>4.0.0</modelVersion><version>2.0.0</version></project>",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "pom.xml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(2, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_pom_xml_ignores_parent_version() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pom.xml"),
|
||||||
|
"<project><parent><version>3.0.0</version></parent><version>2.0.0</version></project>",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].version, Version::new(2, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_pubspec_yaml() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("pubspec.yaml"),
|
||||||
|
"name: myapp\nversion: 1.0.0+1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "pubspec.yaml");
|
||||||
|
assert_eq!(
|
||||||
|
versions[0].version,
|
||||||
|
Version::parse("1.0.0+1").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_go_mod_skips_all() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("go.mod"), "module example.com/m\n").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Cargo.toml"),
|
||||||
|
"[package]\nname = \"test\"\nversion = \"0.3.0\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// go.mod triggers immediate return — Cargo.toml is ignored
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert!(versions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_versions_project_toml_julia() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("Project.toml"),
|
||||||
|
"name = \"MyPkg\"\nversion = \"0.1.0\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let versions = read_project_versions(dir.path());
|
||||||
|
assert_eq!(versions.len(), 1);
|
||||||
|
assert_eq!(versions[0].source, "Project.toml");
|
||||||
|
assert_eq!(versions[0].version, Version::new(0, 1, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -930,6 +930,42 @@ impl Messages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn found_version_in(&self) -> &str {
|
||||||
|
match self.language {
|
||||||
|
Language::English => "Found version in",
|
||||||
|
Language::Chinese => "在配置文件中找到版本",
|
||||||
|
Language::Japanese => "設定ファイルでバージョンが見つかりました",
|
||||||
|
Language::Korean => "구성 파일에서 버전을 찾음",
|
||||||
|
Language::Spanish => "Versión encontrada en",
|
||||||
|
Language::French => "Version trouvée dans",
|
||||||
|
Language::German => "Version gefunden in",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn found_multiple_versions(&self) -> &str {
|
||||||
|
match self.language {
|
||||||
|
Language::English => "Found versions in multiple config files:",
|
||||||
|
Language::Chinese => "在多个配置文件中找到版本:",
|
||||||
|
Language::Japanese => "複数の設定ファイルでバージョンが見つかりました:",
|
||||||
|
Language::Korean => "여러 구성 파일에서 버전을 찾음:",
|
||||||
|
Language::Spanish => "Versiones encontradas en múltiples archivos:",
|
||||||
|
Language::French => "Versions trouvées dans plusieurs fichiers :",
|
||||||
|
Language::German => "Versionen in mehreren Dateien gefunden:",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_version_to_use(&self) -> &str {
|
||||||
|
match self.language {
|
||||||
|
Language::English => "Select version to use",
|
||||||
|
Language::Chinese => "选择要使用的版本",
|
||||||
|
Language::Japanese => "使用するバージョンを選択",
|
||||||
|
Language::Korean => "사용할 버전 선택",
|
||||||
|
Language::Spanish => "Seleccionar versión a usar",
|
||||||
|
Language::French => "Sélectionner la version à utiliser",
|
||||||
|
Language::German => "Zu verwendende Version auswählen",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn profile_description(&self) -> &str {
|
pub fn profile_description(&self) -> &str {
|
||||||
match self.language {
|
match self.language {
|
||||||
Language::English => "Profile description (optional)",
|
Language::English => "Profile description (optional)",
|
||||||
|
|||||||
Reference in New Issue
Block a user