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

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