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()
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -23,4 +23,7 @@ test_output/
|
||||
# Config (for development)
|
||||
config.toml
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
**/agents/
|
||||
.scratch/
|
||||
CONTEXT.md
|
||||
81
AGENTS.md
Normal file
81
AGENTS.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# AGENTS.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
**Important:** Use Chinese for information responses and thinking; use English for searching and querying.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues and specs live as local markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
The five canonical triage roles use their default label strings. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context repo with `CONTEXT.md` and `docs/adr/` at the root. See `docs/agents/domain.md`.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
TagBuilder, VersionBump, bump_version, get_latest_version, read_project_version,
|
||||
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,57 @@ 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
|
||||
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> {
|
||||
let default_msg = format!("Release {}", version);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
158
src/git/mod.rs
158
src/git/mod.rs
@@ -862,24 +862,34 @@ impl GitRepo {
|
||||
let name = String::from_utf8_lossy(name);
|
||||
let name = name.strip_prefix("refs/tags/").unwrap_or(&name);
|
||||
|
||||
if let Ok(commit) = self.repo.find_commit(oid) {
|
||||
tags.push(TagInfo {
|
||||
name: name.to_string(),
|
||||
target: oid.to_string(),
|
||||
message: commit.message().unwrap_or("").to_string(),
|
||||
time: commit.time().seconds(),
|
||||
});
|
||||
// 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: 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()?;
|
||||
@@ -1071,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 {
|
||||
@@ -1441,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
191
src/git/tag.rs
191
src/git/tag.rs
@@ -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,193 @@ 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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user