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:
2026-07-24 17:41:18 +08:00
parent 995d263a48
commit 206bde0786
7 changed files with 651 additions and 36 deletions

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