5 Commits

Author SHA1 Message Date
cc80604710 feat(tag): 支持从多个配置文件读取版本并交互选择
将 `read_project_version` 重构为 `read_project_versions`,支持从 Cargo.toml、package.json、pyproject.toml 等多个配置文件同时读取版本。当检测到多个版本时,提供交互式选择界面让用户决定使用哪个版本。
2026-08-07 15:41:37 +08:00
35fe6e09b7 chore: 将 .qoder/ 添加到 .gitignore 2026-08-07 14:26:48 +08:00
349ff56299 chore(release): bump version to 0.5.0 and update changelog 2026-07-24 17:59:22 +08:00
206bde0786 fix: 修复 tag/changelog 生成范围,增加 --auto 模式
- fix(get_tags): 使用 find_object+peel_to_commit 支持 annotated tag
- fix(changelog): insert_changelog_entry 替换覆盖逻辑,保留已有章节
- feat: parse_changelog_versions 按 semver 降序提取版本
- feat: sort_tags_by_semver 取代纯时间排序
- feat(tag --auto): 优先读 Cargo.toml/pyproject.toml,回退 commit 分析
- feat(changelog): 无 --from 时自动检测 changelog 最高已有版本
- refactor: TagInfo::version_name(), GitRepo::find_tag_by_version()
2026-07-24 17:41:18 +08:00
995d263a48 feat(commit): 添加.gitignore文件过滤功能,自动跳过被忽略的文件
在自动暂存和`--all`模式下,检测并跳过被.gitignore规则匹配的文件,暂存完成后显示被移除的被忽略文件列表
2026-07-20 17:33:57 +08:00
14 changed files with 1448 additions and 68 deletions

6
.gitignore vendored
View File

@@ -6,6 +6,7 @@ Cargo.lock
# IDE
.idea/
.trae/
.vscode/
*.swp
*.swo
@@ -22,4 +23,9 @@ test_output/
# Config (for development)
config.toml
.claude/
.qoder/
CLAUDE.md
**/agents/
adr/
.scratch/
CONTEXT.md

81
AGENTS.md Normal file
View 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`.

View File

@@ -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
### ✨ 新功能

View File

@@ -1,9 +1,9 @@
[package]
name = "quicommit"
version = "0.4.0"
version = "0.5.5"
edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
license = "MIT"
repository = "https://git.lyz.one/SidneyZhang/QuiCommit"
keywords = ["git", "commit", "ai", "cli", "automation"]
@@ -47,6 +47,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# Utilities
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
roxmltree = "0.20"
lazy_static = "1.4"
colored = "2.1"
handlebars = "5.1"

View File

@@ -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
- **Conventional Commits**: Full support for Conventional Commits and commitlint formats
- **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
- **Security**: Use system keyring to store API keys securely
- **Interactive UI**: Beautiful CLI with previews and confirmations
@@ -63,7 +63,7 @@ quicommit commit
# Manual commit
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
# Skip confirmation
@@ -72,16 +72,25 @@ quicommit commit --yes
# Use date-based commit message
quicommit commit --date
# Enable LLM thinking/reasoning mode for this commit
quicommit commit --think
# Push after committing
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
```bash
# Auto-detect version bump
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
quicommit tag --bump minor
@@ -98,11 +107,14 @@ quicommit tag --push
### Generate Changelog
```bash
# Generate for unreleased changes
# Generate for unreleased changes (auto-detects --from from the highest version in the existing changelog)
quicommit changelog
# 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
quicommit changelog --generate
@@ -222,7 +234,7 @@ quicommit profile token
```bash
# Configure Ollama (local)
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
quicommit config set-llm openai
@@ -309,13 +321,14 @@ quicommit config reset --force
| `-b, --breaking` | Mark as breaking change |
| `-d, --date` | Use date-based commit message |
| `--manual` | Manual input, skip AI |
| `-a, --all` | Stage all changes |
| `-a, --all` | Stage all changes (skips `.gitignore`-matched files) |
| `-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 |
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation |
| `--push` | Push after committing |
| `--remote` | Specify remote repository (default: origin) |
@@ -326,6 +339,7 @@ quicommit config reset --force
|--------|-------------|
| `-n, --name` | Tag name |
| `-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 |
| `-g, --generate` | AI-generate message |
| `-S, --sign` | GPG sign tag |
@@ -334,6 +348,7 @@ quicommit config reset --force
| `-p, --push` | Push to remote |
| `-r, --remote` | Specify remote repository (default: origin) |
| `--dry-run` | Dry run |
| `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation |
### Changelog Options
@@ -341,16 +356,16 @@ quicommit config reset --force
| Option | Description |
|--------|-------------|
| `-o, --output` | Output file path |
| `-v, --version` | Generate for specific version |
| `-f, --from` | Generate from specific tag |
| `--version` | Generate for specific version |
| `-f, --from` | Generate from specific tag (auto-detected from existing changelog if omitted) |
| `-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) |
| `--think` | Enable LLM thinking/reasoning mode (overrides config) |
| `-y, --yes` | Skip confirmation |
## Configuration File

View File

@@ -17,7 +17,7 @@
- **AI智能生成**使用LLM APIOllama本地、OpenAI、Anthropic Claude、Kimi、DeepSeek、OpenRouter生成提交信息、标签和变更日志
- **规范化提交**支持Conventional Commits和commitlint格式规范
- **多配置管理**为不同场景管理多个Git身份支持SSH密钥和GPG签名配置
- **智能标签管理**基于语义版本自动检测升级AI生成标签信息
- **智能标签管理**基于语义版本自动检测升级AI生成标签信息;支持从 16+ 种项目配置文件Cargo.toml、package.json、pom.xml、pubspec.yaml 等)自动读取版本
- **变更日志生成**自动生成Keep a Changelog格式的变更日志
- **安全保护**:使用系统密钥环进行安全存储
- **交互式界面**美观的CLI界面支持预览和确认
@@ -62,7 +62,7 @@ quicommit commit
# 手动提交
quicommit commit --manual -t feat -m "添加新功能"
# 暂存所有文件并提交
# 暂存所有文件并提交(自动跳过 .gitignore 匹配的文件)
quicommit commit -a
# 跳过确认直接提交
@@ -71,16 +71,25 @@ quicommit commit --yes
# 使用日期格式的提交信息
quicommit commit --date
# 为本次提交启用 LLM 思考/推理模式
quicommit commit --think
# 提交后推送到远程
quicommit commit --push
```
在暂存更改时(自动暂存或 `--all` 模式),会自动跳过匹配 `.gitignore` 规则的文件,并清理索引中已被忽略的已跟踪文件,最后打印出被跳过的文件列表。
### 创建标签
```bash
# 自动检测版本升级
quicommit tag
# 从项目配置文件Cargo.toml、package.json、pom.xml 等)自动检测版本,
# 回退到基于 commit 的分析
quicommit tag --auto
# 指定版本升级类型
quicommit tag --bump minor
@@ -97,11 +106,14 @@ quicommit tag --push
### 生成变更日志
```bash
# 生成未发布变更的变更日志
# 生成未发布变更的变更日志(未指定 --from 时自动从现有 changelog 的最高版本检测)
quicommit changelog
# 为特定版本生成
quicommit changelog -v 1.0.0
quicommit changelog --version 1.0.0
# 从指定标签生成
quicommit changelog --from v0.9.0
# AI生成变更日志
quicommit changelog --generate
@@ -216,7 +228,7 @@ quicommit profile token
```bash
# 配置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
quicommit config set-llm openai
@@ -303,13 +315,14 @@ quicommit config reset --force
| `-b, --breaking` | 标记为破坏性变更 |
| `-d, --date` | 使用日期格式的提交信息 |
| `--manual` | 手动输入跳过AI生成 |
| `-a, --all` | 暂存所有更改 |
| `-a, --all` | 暂存所有更改(自动跳过 `.gitignore` 匹配的文件) |
| `-S, --sign` | GPG签名提交 |
| `--amend` | 修改上一次提交 |
| `--dry-run` | 试运行,不实际提交 |
| `--conventional` | 使用Conventional Commits格式 |
| `--commitlint` | 使用commitlint格式 |
| `--no-verify` | 不验证提交信息 |
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 |
| `--push` | 提交后推送到远程 |
| `--remote` | 指定远程仓库默认origin |
@@ -320,6 +333,7 @@ quicommit config reset --force
|------|------|
| `-n, --name` | 标签名称 |
| `-b, --bump` | 版本升级类型major/minor/patch |
| `-A, --auto` | 从项目配置文件Cargo.toml、package.json、pom.xml 等 16+ 种)自动检测版本,回退到基于 commit 的分析(与 `--bump` 互斥) |
| `-m, --message` | 标签信息 |
| `-g, --generate` | AI生成标签信息 |
| `-S, --sign` | GPG签名标签 |
@@ -328,6 +342,7 @@ quicommit config reset --force
| `-p, --push` | 推送到远程 |
| `-r, --remote` | 指定远程仓库默认origin |
| `--dry-run` | 试运行 |
| `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 |
### changelog命令选项
@@ -335,16 +350,16 @@ quicommit config reset --force
| 选项 | 说明 |
|------|------|
| `-o, --output` | 输出文件路径 |
| `-v, --version` | 为特定版本生成 |
| `-f, --from` | 从指定标签生成 |
| `--version` | 为特定版本生成 |
| `-f, --from` | 从指定标签生成(未指定时自动从现有 changelog 的最高版本检测) |
| `-t, --to` | 生成到指定引用默认HEAD |
| `-i, --init` | 初始化新的变更日志文件 |
| `-g, --generate` | AI生成变更日志 |
| `--prepend` | 添加到现有变更日志开头 |
| `--include-hashes` | 包含提交哈希 |
| `--include-authors` | 包含作者信息 |
| `--format` | 格式keep-a-changelog、github-releases |
| `--dry-run` | 试运行输出到stdout |
| `--think` | 启用 LLM 思考/推理模式(覆盖配置) |
| `-y, --yes` | 跳过确认提示 |
## 配置文件

View File

@@ -7,6 +7,7 @@ use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::generator::ContentGenerator;
use crate::git::GitRepo;
use crate::git::find_repo;
use crate::git::{CommitInfo, changelog::*};
use crate::i18n::{Messages, translate_changelog_category};
@@ -120,7 +121,10 @@ impl ChangelogCommand {
// Get 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() {
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() {
let existing = std::fs::read_to_string(&output_path)?;
let new_content = if existing.is_empty() {
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 {
format!("{}{}", CHANGELOG_HEADER, changelog)
insert_changelog_entry(&existing, &changelog)
};
std::fs::write(&output_path, new_content)?;
} else {
@@ -206,6 +190,28 @@ impl ChangelogCommand {
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(
&self,
version: &str,

View File

@@ -121,8 +121,17 @@ impl CommitCommand {
// 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()?;
let removed = repo.stage_all()?;
println!("{}", messages.staged_all().green());
if !removed.is_empty() {
println!(
"{}",
format!("Removed {} ignored files from staging:", removed.len()).yellow()
);
for file in &removed {
println!("{}", file);
}
}
// Re-check status after staging to ensure changes are detected
let new_status = repo.status_summary()?;
@@ -133,8 +142,17 @@ impl CommitCommand {
// Stage all if requested
if self.all {
repo.stage_all()?;
let removed = repo.stage_all()?;
println!("{}", messages.staged_all().green());
if !removed.is_empty() {
println!(
"{}",
format!("Removed {} ignored files from staging:", removed.len()).yellow()
);
for file in &removed {
println!("{}", file);
}
}
}
// Generate or build commit message

View File

@@ -8,7 +8,8 @@ use std::path::PathBuf;
use crate::config::{Language, manager::ConfigManager};
use crate::generator::ContentGenerator;
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::i18n::Messages;
@@ -63,6 +64,11 @@ pub struct TagCommand {
/// Skip interactive prompts
#[arg(short = 'y', long)]
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 {
@@ -80,6 +86,9 @@ impl TagCommand {
// Determine tag name
let tag_name = if let Some(name) = &self.name {
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 {
// Calculate bumped version
let prefix = &config.tag.version_prefix;
@@ -325,6 +334,89 @@ impl TagCommand {
.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> {
let default_msg = format!("Release {}", version);

View File

@@ -409,8 +409,8 @@ pub fn generate_from_history(
if let Some(from) = from_tag {
repo.get_commits_between(from, to_ref)
} else {
// Get last 50 commits if no tag specified
repo.get_commits(50)
// Get all commits from the beginning (no from_tag = initial changelog)
repo.get_commits(usize::MAX)
}
}
@@ -476,3 +476,137 @@ impl ChangelogEntry {
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");
}
}

View File

@@ -533,27 +533,116 @@ impl GitRepo {
Ok(files)
}
/// Stage files
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
/// Stage files, skipping paths matched by .gitignore rules
/// Returns the list of skipped paths (those matched by .gitignore)
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<Vec<String>> {
let mut index = self.repo.index()?;
let mut skipped = Vec::new();
for path in paths {
let path = path.as_ref();
if path.is_absolute() {
if let Ok(rel_path) = path.strip_prefix(&self.path) {
index.add_path(rel_path)?;
let rel_path = if path.is_absolute() {
match path.strip_prefix(&self.path) {
Ok(p) => p,
Err(_) => {
// Outside repo, skip
skipped.push(path.to_string_lossy().to_string());
continue;
}
}
} else {
index.add_path(path)?;
path
};
// Check if the path is ignored by .gitignore
if self.is_path_ignored(rel_path)? {
skipped.push(rel_path.to_string_lossy().to_string());
continue;
}
index.add_path(rel_path)?;
}
index.write()?;
Ok(())
Ok(skipped)
}
/// Stage all changes including subdirectories
pub fn stage_all(&self) -> Result<()> {
/// Check if a path is ignored by .gitignore rules
pub fn is_path_ignored<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
let path = path.as_ref();
// Convert to relative path if absolute
let rel_path = if path.is_absolute() {
match path.strip_prefix(&self.path) {
Ok(p) => p,
Err(_) => return Ok(false), // Outside repo, not ignored
}
} else {
path
};
let path_str = match rel_path.to_str() {
Some(s) => s,
None => return Ok(false), // Non-UTF8 path, not ignored
};
let output = std::process::Command::new("git")
.args(["check-ignore", "--quiet", "--", path_str])
.current_dir(&self.path)
.output()?;
// Exit code 0: ignored, 1: not ignored, other: error
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Ok(false), // Treat errors as not ignored
}
}
/// Remove files from index that are tracked but should be ignored by .gitignore
pub fn remove_ignored_from_index(&self) -> Result<Vec<String>> {
let output = std::process::Command::new("git")
.args(["ls-files", "--cached", "-i", "--exclude-standard"])
.current_dir(&self.path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to list ignored tracked files: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let ignored_files: Vec<String> = stdout
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
if ignored_files.is_empty() {
return Ok(Vec::new());
}
// Remove these files from the index (keep working tree files)
let mut args = vec!["rm", "--cached", "--quiet", "--"];
for file in &ignored_files {
args.push(file);
}
let rm_output = std::process::Command::new("git")
.args(&args)
.current_dir(&self.path)
.output()?;
if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
bail!("Failed to remove ignored files from index: {}", stderr);
}
Ok(ignored_files)
}
/// Stage all changes including subdirectories, then remove ignored tracked files
pub fn stage_all(&self) -> Result<Vec<String>> {
// Use git command for reliable staging (handles all edge cases)
let output = std::process::Command::new("git")
.args(["add", "-A"])
@@ -569,7 +658,14 @@ impl GitRepo {
// Force refresh the git2 index to pick up changes from git CLI
let _ = self.repo.index()?.write();
Ok(())
// Remove files that are tracked but should be ignored by .gitignore
match self.remove_ignored_from_index() {
Ok(removed) => Ok(removed),
Err(e) => {
eprintln!("Warning: failed to clean ignored files from index: {}", e);
Ok(Vec::new())
}
}
}
/// Unstage files
@@ -766,24 +862,34 @@ impl GitRepo {
let name = String::from_utf8_lossy(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
// (oid is a commit) and annotated (oid is a tag object) tags.
if let Ok(obj) = self.repo.find_object(oid, None) {
if let Ok(commit) = obj.peel_to_commit() {
tags.push(TagInfo {
name: name.to_string(),
target: oid.to_string(),
target: commit.id().to_string(),
message: commit.message().unwrap_or("").to_string(),
time: commit.time().seconds(),
});
}
}
true
})?;
// Sort tags by time (newest first)
tags.sort_by(|a, b| b.time.cmp(&a.time));
// Sort tags by semver (descending), then by time
crate::git::tag::sort_tags_by_semver(&mut 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
pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> {
let head = self.repo.head()?;
@@ -975,6 +1081,13 @@ pub struct TagInfo {
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
#[derive(Debug, Clone)]
pub struct StatusSummary {
@@ -1345,3 +1458,126 @@ pub struct ConfigDiff {
pub left: 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"
);
}
}

View File

@@ -1,6 +1,7 @@
use super::GitRepo;
use anyhow::{Result, bail};
use semver::Version;
use std::path::Path;
/// Tag builder for creating tags
pub struct TagBuilder {
@@ -327,3 +328,456 @@ pub fn list_tags(
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));
}
}

View File

@@ -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 {
match self.language {
Language::English => "Profile description (optional)",

266
tests/gitignore_tests.rs Normal file
View File

@@ -0,0 +1,266 @@
use quicommit::git::GitRepo;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Run a git command in the given directory, returning stdout as a String.
/// Panics if the command fails.
fn git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("Failed to execute git command");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
panic!("git {:?} failed in {:?}: {}", args, dir, stderr);
}
String::from_utf8_lossy(&output.stdout).to_string()
}
/// Initialize a new git repo in the given directory and configure a local
/// user identity so commits can be created.
fn init_repo(dir: &Path) {
git(dir, &["init"]);
git(dir, &["config", "user.name", "Test User"]);
git(dir, &["config", "user.email", "test@example.com"]);
// Disable commit signing in case the global config enables it.
git(dir, &["config", "commit.gpgsign", "false"]);
}
/// Write a file with the given content, creating parent directories as needed.
fn write_file(dir: &Path, rel_path: &str, content: &str) {
let file_path = dir.join(rel_path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).expect("Failed to create parent directories");
}
fs::write(&file_path, content).expect("Failed to write file");
}
/// Get the list of files in the index as a String (one path per line).
fn ls_files(dir: &Path) -> String {
git(dir, &["ls-files"])
}
// ---------------------------------------------------------------------------
// Tests for is_path_ignored
// ---------------------------------------------------------------------------
#[test]
fn test_is_path_ignored_with_ignored_path() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
write_file(repo_path, "subdir/__pycache__/bar.pyc", "more bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
// Top-level ignored file
assert!(
repo.is_path_ignored("__pycache__/foo.pyc").unwrap(),
"__pycache__/foo.pyc should be ignored"
);
// Nested ignored file under a subdirectory
assert!(
repo.is_path_ignored("subdir/__pycache__/bar.pyc").unwrap(),
"subdir/__pycache__/bar.pyc should be ignored"
);
}
#[test]
fn test_is_path_ignored_with_non_ignored_path() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
assert!(
!repo.is_path_ignored("src/main.rs").unwrap(),
"src/main.rs should not be ignored"
);
}
// ---------------------------------------------------------------------------
// Tests for stage_all
// ---------------------------------------------------------------------------
#[test]
fn test_stage_all_removes_ignored_tracked_files() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
// Create and commit __pycache__/foo.pyc so it becomes a tracked file.
write_file(repo_path, "__pycache__/foo.pyc", "original bytecode");
git(repo_path, &["add", "__pycache__/foo.pyc"]);
git(repo_path, &["commit", "-m", "initial commit"]);
// Add a .gitignore that now ignores __pycache__/.
write_file(repo_path, ".gitignore", "__pycache__/\n");
// Modify the tracked file so the working tree has unstaged changes.
write_file(repo_path, "__pycache__/foo.pyc", "modified bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let removed = repo.stage_all().expect("stage_all should succeed");
assert!(
removed.iter().any(|f| f == "__pycache__/foo.pyc"),
"stage_all should return __pycache__/foo.pyc in removed list, got: {:?}",
removed
);
// Verify the file is no longer in the index.
let files = ls_files(repo_path);
assert!(
!files.lines().any(|l| l == "__pycache__/foo.pyc"),
"__pycache__/foo.pyc should no longer be in the index, got: {}",
files
);
}
#[test]
fn test_stage_all_no_ignored_files_returns_empty() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "*.log\n");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let removed = repo.stage_all().expect("stage_all should succeed");
assert!(
removed.is_empty(),
"stage_all should return empty Vec when no ignored files are tracked, got: {:?}",
removed
);
// Verify src/main.rs was staged.
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be in the index, got: {}",
files
);
}
// ---------------------------------------------------------------------------
// Tests for stage_files
// ---------------------------------------------------------------------------
#[test]
fn test_stage_files_skips_ignored_paths() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
write_file(repo_path, "src/main.rs", "fn main() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["__pycache__/foo.pyc", "src/main.rs"])
.expect("stage_files should succeed");
assert!(
skipped.iter().any(|f| f == "__pycache__/foo.pyc"),
"skipped list should contain __pycache__/foo.pyc, got: {:?}",
skipped
);
assert!(
!skipped.iter().any(|f| f == "src/main.rs"),
"skipped list should not contain src/main.rs, got: {:?}",
skipped
);
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be staged, got: {}",
files
);
assert!(
!files.lines().any(|l| l == "__pycache__/foo.pyc"),
"__pycache__/foo.pyc should not be staged, got: {}",
files
);
}
#[test]
fn test_stage_files_all_paths_ignored() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
write_file(repo_path, ".gitignore", "__pycache__/\n");
write_file(repo_path, "__pycache__/foo.pyc", "bytecode");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["__pycache__/foo.pyc"])
.expect("stage_files should succeed");
assert!(
skipped.iter().any(|f| f == "__pycache__/foo.pyc"),
"skipped list should contain __pycache__/foo.pyc, got: {:?}",
skipped
);
// Verify the index remains empty (the file was not staged).
let files = ls_files(repo_path);
assert!(
files.trim().is_empty(),
"index should be empty, got: {}",
files
);
}
#[test]
fn test_stage_files_normal_paths_unchanged_behavior() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path();
init_repo(repo_path);
// No .gitignore (or one that does not match these paths).
write_file(repo_path, "src/main.rs", "fn main() {}");
write_file(repo_path, "src/lib.rs", "pub fn lib() {}");
let repo = GitRepo::open(repo_path).expect("Failed to open repo");
let skipped = repo
.stage_files(&["src/main.rs", "src/lib.rs"])
.expect("stage_files should succeed");
assert!(
skipped.is_empty(),
"skipped list should be empty, got: {:?}",
skipped
);
let files = ls_files(repo_path);
assert!(
files.lines().any(|l| l == "src/main.rs"),
"src/main.rs should be staged, got: {}",
files
);
assert!(
files.lines().any(|l| l == "src/lib.rs"),
"src/lib.rs should be staged, got: {}",
files
);
}