3 Commits
v0.4.0 ... main

Author SHA1 Message Date
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
13 changed files with 1110 additions and 66 deletions

6
.gitignore vendored
View File

@@ -6,6 +6,7 @@ Cargo.lock
# IDE # IDE
.idea/ .idea/
.trae/
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo
@@ -22,4 +23,7 @@ test_output/
# Config (for development) # Config (for development)
config.toml config.toml
.claude/ .claude/
CLAUDE.md CLAUDE.md
**/agents/
.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 ## [0.4.0] - 2026-07-16
### ✨ 新功能 ### ✨ 新功能

View File

@@ -1,9 +1,9 @@
[package] [package]
name = "quicommit" name = "quicommit"
version = "0.4.0" version = "0.5.0"
edition = "2024" edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"] authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)" description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
license = "MIT" license = "MIT"
repository = "https://git.lyz.one/SidneyZhang/QuiCommit" repository = "https://git.lyz.one/SidneyZhang/QuiCommit"
keywords = ["git", "commit", "ai", "cli", "automation"] keywords = ["git", "commit", "ai", "cli", "automation"]

View File

@@ -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,24 @@ 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 Cargo.toml/pyproject.toml, fall back to commit analysis
quicommit tag --auto
# Specify bump type # Specify bump type
quicommit tag --bump minor quicommit tag --bump minor
@@ -98,11 +106,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 +233,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 +320,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 +338,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 Cargo.toml/pyproject.toml, 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 +347,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 +355,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

View File

@@ -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,24 @@ 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/pyproject.toml 自动检测版本,回退到基于 commit 的分析
quicommit tag --auto
# 指定版本升级类型 # 指定版本升级类型
quicommit tag --bump minor quicommit tag --bump minor
@@ -97,11 +105,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 +227,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 +314,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 +332,7 @@ quicommit config reset --force
|------|------| |------|------|
| `-n, --name` | 标签名称 | | `-n, --name` | 标签名称 |
| `-b, --bump` | 版本升级类型major/minor/patch | | `-b, --bump` | 版本升级类型major/minor/patch |
| `-A, --auto` | 从 Cargo.toml/pyproject.toml 自动检测版本,回退到基于 commit 的分析(与 `--bump` 互斥) |
| `-m, --message` | 标签信息 | | `-m, --message` | 标签信息 |
| `-g, --generate` | AI生成标签信息 | | `-g, --generate` | AI生成标签信息 |
| `-S, --sign` | GPG签名标签 | | `-S, --sign` | GPG签名标签 |
@@ -328,6 +341,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 +349,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` | 跳过确认提示 |
## 配置文件 ## 配置文件

View File

@@ -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,

View File

@@ -121,8 +121,17 @@ impl CommitCommand {
// Auto-add if no files are staged and there are unstaged/untracked changes // 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 { if status.staged == 0 && (status.unstaged > 0 || status.untracked > 0) && !self.all {
println!("{}", messages.auto_stage_changes().yellow()); println!("{}", messages.auto_stage_changes().yellow());
repo.stage_all()?; let removed = repo.stage_all()?;
println!("{}", messages.staged_all().green()); 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 // Re-check status after staging to ensure changes are detected
let new_status = repo.status_summary()?; let new_status = repo.status_summary()?;
@@ -133,8 +142,17 @@ impl CommitCommand {
// Stage all if requested // Stage all if requested
if self.all { if self.all {
repo.stage_all()?; let removed = repo.stage_all()?;
println!("{}", messages.staged_all().green()); 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 // Generate or build commit message

View File

@@ -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, TagBuilder, VersionBump, bump_version, get_latest_version, read_project_version,
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,57 @@ 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
if let Some(version) = read_project_version(&project_dir) {
let tag_name = format!("{}{}", prefix, version);
println!(
"{} {}",
messages.latest_version(),
tag_name.cyan()
);
return Ok(tag_name);
}
// 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 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);

View File

@@ -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");
}
}

View File

@@ -533,27 +533,116 @@ impl GitRepo {
Ok(files) Ok(files)
} }
/// Stage files /// Stage files, skipping paths matched by .gitignore rules
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> { /// 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 index = self.repo.index()?;
let mut skipped = Vec::new();
for path in paths { for path in paths {
let path = path.as_ref(); let path = path.as_ref();
if path.is_absolute() { let rel_path = if path.is_absolute() {
if let Ok(rel_path) = path.strip_prefix(&self.path) { match path.strip_prefix(&self.path) {
index.add_path(rel_path)?; Ok(p) => p,
Err(_) => {
// Outside repo, skip
skipped.push(path.to_string_lossy().to_string());
continue;
}
} }
} else { } 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()?; index.write()?;
Ok(()) Ok(skipped)
} }
/// Stage all changes including subdirectories /// Check if a path is ignored by .gitignore rules
pub fn stage_all(&self) -> Result<()> { 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) // Use git command for reliable staging (handles all edge cases)
let output = std::process::Command::new("git") let output = std::process::Command::new("git")
.args(["add", "-A"]) .args(["add", "-A"])
@@ -569,7 +658,14 @@ impl GitRepo {
// Force refresh the git2 index to pick up changes from git CLI // Force refresh the git2 index to pick up changes from git CLI
let _ = self.repo.index()?.write(); 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 /// Unstage files
@@ -766,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()?;
@@ -975,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 {
@@ -1345,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"
);
}
}

View File

@@ -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,193 @@ 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
}
});
}
/// Read the project version from Cargo.toml or pyproject.toml.
/// Returns None if no project config file is found or version cannot be read.
pub fn read_project_version(project_dir: &Path) -> Option<Version> {
// Try Cargo.toml first
let cargo_path = project_dir.join("Cargo.toml");
if cargo_path.exists() {
if let Ok(content) = std::fs::read_to_string(&cargo_path) {
if let Ok(value) = content.parse::<toml::Value>() {
if let Some(version) = value
.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
if let Ok(ver) = Version::parse(version) {
return Some(ver);
}
}
}
}
}
// Try pyproject.toml
let pyproject_path = project_dir.join("pyproject.toml");
if pyproject_path.exists() {
if let Ok(content) = std::fs::read_to_string(&pyproject_path) {
if let Ok(value) = content.parse::<toml::Value>() {
if let Some(version) = value
.get("project")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
{
if let Ok(ver) = Version::parse(version) {
return Some(ver);
}
}
}
}
}
None
}
#[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_project_version_from_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 version = read_project_version(dir.path());
assert_eq!(version, Some(Version::new(0, 3, 0)));
}
#[test]
fn test_read_project_version_from_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 version = read_project_version(dir.path());
assert_eq!(version, Some(Version::new(0, 2, 1)));
}
#[test]
fn test_read_project_version_no_config_files() {
let dir = tempfile::TempDir::new().unwrap();
let version = read_project_version(dir.path());
assert_eq!(version, None);
}
#[test]
fn test_read_project_version_cargo_takes_priority() {
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 version = read_project_version(dir.path());
assert_eq!(version, Some(Version::new(0, 3, 0)));
}
}

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
);
}