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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,3 +24,6 @@ test_output/
|
|||||||
config.toml
|
config.toml
|
||||||
.claude/
|
.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::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 {
|
} else {
|
||||||
break;
|
insert_changelog_entry(&existing, &changelog)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
};
|
};
|
||||||
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
148
src/git/mod.rs
148
src/git/mod.rs
@@ -862,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
|
||||||
|
// (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 {
|
tags.push(TagInfo {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
target: oid.to_string(),
|
target: commit.id().to_string(),
|
||||||
message: commit.message().unwrap_or("").to_string(),
|
message: commit.message().unwrap_or("").to_string(),
|
||||||
time: commit.time().seconds(),
|
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()?;
|
||||||
@@ -1071,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 {
|
||||||
@@ -1441,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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
191
src/git/tag.rs
191
src/git/tag.rs
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user