style: 格式化代码并优化导入顺序
This commit is contained in:
@@ -95,9 +95,7 @@ impl ChangelogGenerator {
|
||||
ChangelogFormat::GitHubReleases => {
|
||||
self.generate_github_releases(version, date, commits)
|
||||
}
|
||||
ChangelogFormat::Custom => {
|
||||
self.generate_custom(version, date, commits)
|
||||
}
|
||||
ChangelogFormat::Custom => self.generate_custom(version, date, commits),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +108,13 @@ impl ChangelogGenerator {
|
||||
commits: &[CommitInfo],
|
||||
) -> Result<()> {
|
||||
let entry = self.generate(version, date, commits)?;
|
||||
|
||||
|
||||
let existing = if changelog_path.exists() {
|
||||
fs::read_to_string(changelog_path)?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
|
||||
let new_content = if existing.is_empty() {
|
||||
format!("{}{}", CHANGELOG_HEADER, entry)
|
||||
} else if existing.starts_with(CHANGELOG_HEADER) {
|
||||
@@ -124,7 +122,7 @@ impl ChangelogGenerator {
|
||||
} 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;
|
||||
@@ -134,18 +132,18 @@ impl ChangelogGenerator {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let header = lines[..header_end].join("\n");
|
||||
let rest = lines[header_end..].join("\n");
|
||||
|
||||
|
||||
format!("{}\n{}\n{}", header, entry, rest)
|
||||
} else {
|
||||
format!("{}{}", CHANGELOG_HEADER, entry)
|
||||
};
|
||||
|
||||
|
||||
fs::write(changelog_path, new_content)
|
||||
.with_context(|| format!("Failed to write changelog: {:?}", changelog_path))?;
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -157,10 +155,10 @@ impl ChangelogGenerator {
|
||||
) -> Result<String> {
|
||||
let date_str = date.format("%Y-%m-%d").to_string();
|
||||
let mut output = format!("## [{}] - {}\n\n", version, date_str);
|
||||
|
||||
|
||||
if self.group_by_type {
|
||||
let _grouped = self.group_commits(commits);
|
||||
|
||||
|
||||
// Standard categories
|
||||
let categories = vec![
|
||||
("Added", vec!["feat"]),
|
||||
@@ -170,7 +168,7 @@ impl ChangelogGenerator {
|
||||
("Fixed", vec!["fix"]),
|
||||
("Security", vec!["security"]),
|
||||
];
|
||||
|
||||
|
||||
for (title, types) in &categories {
|
||||
let items: Vec<&CommitInfo> = commits
|
||||
.iter()
|
||||
@@ -182,7 +180,7 @@ impl ChangelogGenerator {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
if !items.is_empty() {
|
||||
output.push_str(&format!("### {}\n\n", title));
|
||||
for commit in items {
|
||||
@@ -192,13 +190,13 @@ impl ChangelogGenerator {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Other changes
|
||||
let categorized: Vec<String> = categories
|
||||
.iter()
|
||||
.flat_map(|(_, types)| types.iter().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
|
||||
let other: Vec<&CommitInfo> = commits
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
@@ -209,7 +207,7 @@ impl ChangelogGenerator {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
if !other.is_empty() {
|
||||
output.push_str("### Other\n\n");
|
||||
for commit in other {
|
||||
@@ -224,7 +222,7 @@ impl ChangelogGenerator {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -235,19 +233,19 @@ impl ChangelogGenerator {
|
||||
commits: &[CommitInfo],
|
||||
) -> Result<String> {
|
||||
let mut output = "## What's Changed\n\n".to_string();
|
||||
|
||||
|
||||
// Group by type
|
||||
let mut features = vec![];
|
||||
let mut fixes = vec![];
|
||||
let mut docs = vec![];
|
||||
let mut other = vec![];
|
||||
let mut breaking = vec![];
|
||||
|
||||
|
||||
for commit in commits {
|
||||
if commit.message.contains("BREAKING CHANGE") {
|
||||
breaking.push(commit);
|
||||
}
|
||||
|
||||
|
||||
if let Some(ref t) = commit.commit_type() {
|
||||
match t.as_str() {
|
||||
"feat" => features.push(commit),
|
||||
@@ -259,7 +257,7 @@ impl ChangelogGenerator {
|
||||
other.push(commit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !breaking.is_empty() {
|
||||
output.push_str("### ⚠ Breaking Changes\n\n");
|
||||
for commit in breaking {
|
||||
@@ -267,7 +265,7 @@ impl ChangelogGenerator {
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
|
||||
if !features.is_empty() {
|
||||
output.push_str("### 🚀 Features\n\n");
|
||||
for commit in features {
|
||||
@@ -275,7 +273,7 @@ impl ChangelogGenerator {
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
|
||||
if !fixes.is_empty() {
|
||||
output.push_str("### 🐛 Bug Fixes\n\n");
|
||||
for commit in fixes {
|
||||
@@ -283,7 +281,7 @@ impl ChangelogGenerator {
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
|
||||
if !docs.is_empty() {
|
||||
output.push_str("### 📚 Documentation\n\n");
|
||||
for commit in docs {
|
||||
@@ -291,14 +289,14 @@ impl ChangelogGenerator {
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
|
||||
if !other.is_empty() {
|
||||
output.push_str("### Other Changes\n\n");
|
||||
for commit in other {
|
||||
output.push_str(&self.format_commit_github(commit));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -312,7 +310,7 @@ impl ChangelogGenerator {
|
||||
if !self.custom_categories.is_empty() {
|
||||
let date_str = date.format("%Y-%m-%d").to_string();
|
||||
let mut output = format!("## [{}] - {}\n\n", version, date_str);
|
||||
|
||||
|
||||
for category in &self.custom_categories {
|
||||
let items: Vec<&CommitInfo> = commits
|
||||
.iter()
|
||||
@@ -324,7 +322,7 @@ impl ChangelogGenerator {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
if !items.is_empty() {
|
||||
output.push_str(&format!("### {}\n\n", category.title));
|
||||
for commit in items {
|
||||
@@ -334,7 +332,7 @@ impl ChangelogGenerator {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(output)
|
||||
} else {
|
||||
// Fall back to keep-a-changelog
|
||||
@@ -344,30 +342,35 @@ impl ChangelogGenerator {
|
||||
|
||||
fn format_commit(&self, commit: &CommitInfo) -> String {
|
||||
let mut line = format!("- {}", commit.subject());
|
||||
|
||||
|
||||
if self.include_hashes {
|
||||
line.push_str(&format!(" ({})", &commit.short_id));
|
||||
}
|
||||
|
||||
|
||||
if self.include_authors {
|
||||
line.push_str(&format!(" - @{}", commit.author));
|
||||
}
|
||||
|
||||
|
||||
line
|
||||
}
|
||||
|
||||
fn format_commit_github(&self, commit: &CommitInfo) -> String {
|
||||
format!("- {} by @{} in {}\n", commit.subject(), commit.author, &commit.short_id)
|
||||
format!(
|
||||
"- {} by @{} in {}\n",
|
||||
commit.subject(),
|
||||
commit.author,
|
||||
&commit.short_id
|
||||
)
|
||||
}
|
||||
|
||||
fn group_commits<'a>(&self, commits: &'a [CommitInfo]) -> HashMap<String, Vec<&'a CommitInfo>> {
|
||||
let mut groups: HashMap<String, Vec<&'a CommitInfo>> = HashMap::new();
|
||||
|
||||
|
||||
for commit in commits {
|
||||
let commit_type = commit.commit_type().unwrap_or_else(|| "other".to_string());
|
||||
groups.entry(commit_type).or_default().push(commit);
|
||||
}
|
||||
|
||||
|
||||
groups
|
||||
}
|
||||
}
|
||||
@@ -380,8 +383,7 @@ impl Default for ChangelogGenerator {
|
||||
|
||||
/// Read existing changelog
|
||||
pub fn read_changelog(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read changelog: {:?}", path))
|
||||
fs::read_to_string(path).with_context(|| format!("Failed to read changelog: {:?}", path))
|
||||
}
|
||||
|
||||
/// Initialize new changelog file
|
||||
@@ -389,10 +391,10 @@ pub fn init_changelog(path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
anyhow::bail!("Changelog already exists at {:?}", path);
|
||||
}
|
||||
|
||||
|
||||
fs::write(path, CHANGELOG_HEADER)
|
||||
.with_context(|| format!("Failed to create changelog: {:?}", path))?;
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -403,7 +405,7 @@ pub fn generate_from_history(
|
||||
to_ref: Option<&str>,
|
||||
) -> Result<Vec<CommitInfo>> {
|
||||
let to_ref = to_ref.unwrap_or("HEAD");
|
||||
|
||||
|
||||
if let Some(from) = from_tag {
|
||||
repo.get_commits_between(from, to_ref)
|
||||
} else {
|
||||
@@ -413,11 +415,7 @@ pub fn generate_from_history(
|
||||
}
|
||||
|
||||
/// Update version links in changelog
|
||||
pub fn update_version_links(
|
||||
changelog: &str,
|
||||
version: &str,
|
||||
compare_url: &str,
|
||||
) -> String {
|
||||
pub fn update_version_links(changelog: &str, version: &str, compare_url: &str) -> String {
|
||||
// Add version link at the end of changelog
|
||||
format!("{}\n[{}]: {}\n", changelog, version, compare_url)
|
||||
}
|
||||
@@ -425,27 +423,29 @@ pub fn update_version_links(
|
||||
/// Parse changelog to extract versions
|
||||
pub fn parse_versions(changelog: &str) -> Vec<(String, String)> {
|
||||
let mut versions = vec![];
|
||||
|
||||
|
||||
for line in changelog.lines() {
|
||||
if line.starts_with("## [")
|
||||
&& let Some(start) = line.find('[')
|
||||
&& let Some(end) = line.find(']') {
|
||||
let version = &line[start + 1..end];
|
||||
if version != "Unreleased"
|
||||
&& let Some(date_start) = line.find(" - ") {
|
||||
let date = &line[date_start + 3..].trim();
|
||||
versions.push((version.to_string(), date.to_string()));
|
||||
}
|
||||
}
|
||||
&& let Some(end) = line.find(']')
|
||||
{
|
||||
let version = &line[start + 1..end];
|
||||
if version != "Unreleased"
|
||||
&& let Some(date_start) = line.find(" - ")
|
||||
{
|
||||
let date = &line[date_start + 3..].trim();
|
||||
versions.push((version.to_string(), date.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
versions
|
||||
}
|
||||
|
||||
/// Get unreleased changes
|
||||
pub fn get_unreleased_changes(repo: &GitRepo) -> Result<Vec<CommitInfo>> {
|
||||
let tags = repo.get_tags()?;
|
||||
|
||||
|
||||
if let Some(latest_tag) = tags.first() {
|
||||
repo.get_commits_between(&latest_tag.name, "HEAD")
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::GitRepo;
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{Result, bail};
|
||||
use chrono::Local;
|
||||
|
||||
/// Commit builder for creating commits
|
||||
@@ -119,10 +119,14 @@ impl CommitBuilder {
|
||||
return Ok(msg.clone());
|
||||
}
|
||||
|
||||
let commit_type = self.commit_type.as_ref()
|
||||
let commit_type = self
|
||||
.commit_type
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Commit type is required"))?;
|
||||
|
||||
let description = self.description.as_ref()
|
||||
|
||||
let description = self
|
||||
.description
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Description is required"))?;
|
||||
|
||||
let message = match self.format {
|
||||
@@ -166,45 +170,46 @@ impl CommitBuilder {
|
||||
|
||||
fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
|
||||
let mut args = vec!["commit", "--amend"];
|
||||
|
||||
|
||||
if self.no_verify {
|
||||
args.push("--no-verify");
|
||||
}
|
||||
|
||||
|
||||
args.push("-m");
|
||||
args.push(message);
|
||||
|
||||
|
||||
if self.sign {
|
||||
args.push("-S");
|
||||
}
|
||||
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(repo.path())
|
||||
.output()?;
|
||||
|
||||
|
||||
if !output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
|
||||
let error_msg = if stderr.is_empty() {
|
||||
if stdout.is_empty() {
|
||||
"GPG signing failed. Please check:\n\
|
||||
1. GPG signing key is configured (git config --get user.signingkey)\n\
|
||||
2. GPG agent is running\n\
|
||||
3. You can sign commits manually (try: git commit --amend -S)".to_string()
|
||||
3. You can sign commits manually (try: git commit --amend -S)"
|
||||
.to_string()
|
||||
} else {
|
||||
stdout.to_string()
|
||||
}
|
||||
} else {
|
||||
stderr.to_string()
|
||||
};
|
||||
|
||||
|
||||
bail!("Failed to amend commit: {}", error_msg);
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -219,7 +224,7 @@ impl Default for CommitBuilder {
|
||||
pub fn create_date_commit_message(prefix: Option<&str>) -> String {
|
||||
let now = Local::now();
|
||||
let date_str = now.format("%Y-%m-%d").to_string();
|
||||
|
||||
|
||||
match prefix {
|
||||
Some(p) => format!("{}: {}", p, date_str),
|
||||
None => format!("chore: update {}", date_str),
|
||||
@@ -229,58 +234,65 @@ pub fn create_date_commit_message(prefix: Option<&str>) -> String {
|
||||
/// Commit type suggestions based on diff
|
||||
pub fn suggest_commit_type(diff: &str) -> Vec<&'static str> {
|
||||
let mut suggestions = vec![];
|
||||
|
||||
|
||||
// Check for test files
|
||||
if diff.contains("test") || diff.contains("spec") || diff.contains("__tests__") {
|
||||
suggestions.push("test");
|
||||
}
|
||||
|
||||
|
||||
// Check for documentation
|
||||
if diff.contains("README") || diff.contains(".md") || diff.contains("docs/") {
|
||||
suggestions.push("docs");
|
||||
}
|
||||
|
||||
|
||||
// Check for configuration files
|
||||
if diff.contains("config") || diff.contains(".json") || diff.contains(".yaml") || diff.contains(".toml") {
|
||||
if diff.contains("config")
|
||||
|| diff.contains(".json")
|
||||
|| diff.contains(".yaml")
|
||||
|| diff.contains(".toml")
|
||||
{
|
||||
suggestions.push("chore");
|
||||
}
|
||||
|
||||
|
||||
// Check for dependencies
|
||||
if diff.contains("Cargo.toml") || diff.contains("package.json") || diff.contains("requirements.txt") {
|
||||
if diff.contains("Cargo.toml")
|
||||
|| diff.contains("package.json")
|
||||
|| diff.contains("requirements.txt")
|
||||
{
|
||||
suggestions.push("build");
|
||||
}
|
||||
|
||||
|
||||
// Check for CI
|
||||
if diff.contains(".github/") || diff.contains(".gitlab-") || diff.contains("Jenkinsfile") {
|
||||
suggestions.push("ci");
|
||||
}
|
||||
|
||||
|
||||
// Default suggestions
|
||||
if suggestions.is_empty() {
|
||||
suggestions.extend(&["feat", "fix", "refactor"]);
|
||||
}
|
||||
|
||||
|
||||
suggestions
|
||||
}
|
||||
|
||||
/// Parse existing commit message
|
||||
pub fn parse_commit_message(message: &str) -> ParsedCommit {
|
||||
let lines: Vec<&str> = message.lines().collect();
|
||||
|
||||
|
||||
if lines.is_empty() {
|
||||
return ParsedCommit::default();
|
||||
}
|
||||
|
||||
|
||||
let first_line = lines[0];
|
||||
|
||||
|
||||
// Try to parse as conventional commit
|
||||
if let Some(colon_pos) = first_line.find(':') {
|
||||
let type_part = &first_line[..colon_pos];
|
||||
let description = first_line[colon_pos + 1..].trim();
|
||||
|
||||
|
||||
let breaking = type_part.ends_with('!');
|
||||
let type_part = type_part.trim_end_matches('!');
|
||||
|
||||
|
||||
let (commit_type, scope) = if let Some(open) = type_part.find('(') {
|
||||
if let Some(close) = type_part.find(')') {
|
||||
let t = &type_part[..open];
|
||||
@@ -292,42 +304,51 @@ pub fn parse_commit_message(message: &str) -> ParsedCommit {
|
||||
} else {
|
||||
(Some(type_part.to_string()), None)
|
||||
};
|
||||
|
||||
|
||||
// Extract body and footer
|
||||
let mut body_lines = vec![];
|
||||
let mut footer_lines = vec![];
|
||||
let mut in_footer = false;
|
||||
|
||||
|
||||
for line in &lines[1..] {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with("BREAKING CHANGE:") ||
|
||||
line.starts_with("Closes") ||
|
||||
line.starts_with("Fixes") ||
|
||||
line.starts_with("Refs") ||
|
||||
line.starts_with("Co-authored-by:") {
|
||||
|
||||
if line.starts_with("BREAKING CHANGE:")
|
||||
|| line.starts_with("Closes")
|
||||
|| line.starts_with("Fixes")
|
||||
|| line.starts_with("Refs")
|
||||
|| line.starts_with("Co-authored-by:")
|
||||
{
|
||||
in_footer = true;
|
||||
}
|
||||
|
||||
|
||||
if in_footer {
|
||||
footer_lines.push(line.to_string());
|
||||
} else {
|
||||
body_lines.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return ParsedCommit {
|
||||
commit_type,
|
||||
scope,
|
||||
description: Some(description.to_string()),
|
||||
body: if body_lines.is_empty() { None } else { Some(body_lines.join("\n")) },
|
||||
footer: if footer_lines.is_empty() { None } else { Some(footer_lines.join("\n")) },
|
||||
body: if body_lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(body_lines.join("\n"))
|
||||
},
|
||||
footer: if footer_lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(footer_lines.join("\n"))
|
||||
},
|
||||
breaking,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Non-conventional commit
|
||||
ParsedCommit {
|
||||
description: Some(first_line.to_string()),
|
||||
@@ -351,7 +372,7 @@ impl ParsedCommit {
|
||||
pub fn to_message(&self, format: crate::config::CommitFormat) -> String {
|
||||
let commit_type = self.commit_type.as_deref().unwrap_or("chore");
|
||||
let description = self.description.as_deref().unwrap_or("update");
|
||||
|
||||
|
||||
match format {
|
||||
crate::config::CommitFormat::Conventional => {
|
||||
crate::utils::formatter::format_conventional_commit(
|
||||
|
||||
301
src/git/mod.rs
301
src/git/mod.rs
@@ -1,49 +1,49 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType};
|
||||
use std::path::{Path, PathBuf, Component};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use git2::{Config, ObjectType, Oid, Repository, Signature, StatusOptions};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub mod changelog;
|
||||
pub mod commit;
|
||||
pub mod tag;
|
||||
|
||||
|
||||
fn normalize_path_for_git2(path: &Path) -> PathBuf {
|
||||
let mut normalized = path.to_path_buf();
|
||||
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.starts_with(r"\\?\")
|
||||
&& let Some(stripped) = path_str.strip_prefix(r"\\?\") {
|
||||
normalized = PathBuf::from(stripped);
|
||||
}
|
||||
&& let Some(stripped) = path_str.strip_prefix(r"\\?\")
|
||||
{
|
||||
normalized = PathBuf::from(stripped);
|
||||
}
|
||||
if path_str.starts_with(r"\\?\UNC\")
|
||||
&& let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
|
||||
normalized = PathBuf::from(format!(r"\\{}", stripped));
|
||||
}
|
||||
&& let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\")
|
||||
{
|
||||
normalized = PathBuf::from(format!(r"\\{}", stripped));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn get_absolute_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
|
||||
|
||||
if path.is_absolute() {
|
||||
return Ok(normalize_path_for_git2(path));
|
||||
}
|
||||
|
||||
let current_dir = std::env::current_dir()
|
||||
.with_context(|| "Failed to get current directory")?;
|
||||
|
||||
|
||||
let current_dir = std::env::current_dir().with_context(|| "Failed to get current directory")?;
|
||||
|
||||
let absolute = current_dir.join(path);
|
||||
Ok(normalize_path_for_git2(&absolute))
|
||||
}
|
||||
|
||||
fn resolve_path_without_canonicalize(path: &Path) -> PathBuf {
|
||||
let mut components = Vec::new();
|
||||
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
@@ -57,25 +57,25 @@ fn resolve_path_without_canonicalize(path: &Path) -> PathBuf {
|
||||
_ => components.push(component),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let mut result = PathBuf::new();
|
||||
for component in components {
|
||||
result.push(component.as_os_str());
|
||||
}
|
||||
|
||||
|
||||
normalize_path_for_git2(&result)
|
||||
}
|
||||
|
||||
fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
|
||||
let normalized = normalize_path_for_git2(path);
|
||||
|
||||
|
||||
let discover_opts = git2::RepositoryOpenFlags::empty();
|
||||
let ceiling_dirs: [&str; 0] = [];
|
||||
|
||||
|
||||
let repo = Repository::open_ext(&normalized, discover_opts, ceiling_dirs)
|
||||
.or_else(|_| Repository::discover(&normalized))
|
||||
.or_else(|_| Repository::open(&normalized));
|
||||
|
||||
|
||||
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e))
|
||||
}
|
||||
|
||||
@@ -85,34 +85,34 @@ fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.context("Failed to execute git command")?;
|
||||
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("git CLI failed to find repository");
|
||||
}
|
||||
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let git_root = stdout.trim();
|
||||
|
||||
|
||||
if git_root.is_empty() {
|
||||
bail!("git CLI returned empty path");
|
||||
}
|
||||
|
||||
|
||||
let git_root_path = PathBuf::from(git_root);
|
||||
let normalized = normalize_path_for_git2(&git_root_path);
|
||||
|
||||
|
||||
Repository::open(&normalized)
|
||||
.with_context(|| format!("Failed to open repo from git CLI path: {:?}", normalized))
|
||||
}
|
||||
|
||||
fn diagnose_repo_issue(path: &Path) -> String {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
|
||||
if !path.exists() {
|
||||
issues.push(format!("Path does not exist: {:?}", path));
|
||||
} else if !path.is_dir() {
|
||||
issues.push(format!("Path is not a directory: {:?}", path));
|
||||
}
|
||||
|
||||
|
||||
let git_dir = path.join(".git");
|
||||
if git_dir.exists() {
|
||||
if git_dir.is_dir() {
|
||||
@@ -128,7 +128,7 @@ fn diagnose_repo_issue(path: &Path) -> String {
|
||||
}
|
||||
} else {
|
||||
issues.push("No .git found in current directory".to_string());
|
||||
|
||||
|
||||
let mut current = path;
|
||||
let mut depth = 0;
|
||||
while let Some(parent) = current.parent() {
|
||||
@@ -144,7 +144,7 @@ fn diagnose_repo_issue(path: &Path) -> String {
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let path_str = path.to_string_lossy();
|
||||
@@ -155,11 +155,11 @@ fn diagnose_repo_issue(path: &Path) -> String {
|
||||
issues.push("WARNING: Path has mixed path separators".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let Ok(current_dir) = std::env::current_dir() {
|
||||
issues.push(format!("Current working directory: {:?}", current_dir));
|
||||
}
|
||||
|
||||
|
||||
issues.join("\n ")
|
||||
}
|
||||
|
||||
@@ -172,17 +172,15 @@ pub struct GitRepo {
|
||||
impl GitRepo {
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
|
||||
|
||||
let absolute_path = get_absolute_path(path)?;
|
||||
let resolved_path = resolve_path_without_canonicalize(&absolute_path);
|
||||
|
||||
let repo = try_open_repo_with_git2(&resolved_path)
|
||||
.or_else(|git2_err| {
|
||||
try_open_repo_with_git_cli(&resolved_path)
|
||||
.map_err(|cli_err| {
|
||||
let diagnosis = diagnose_repo_issue(&resolved_path);
|
||||
anyhow::anyhow!(
|
||||
"Failed to open git repository:\n\
|
||||
|
||||
let repo = try_open_repo_with_git2(&resolved_path).or_else(|git2_err| {
|
||||
try_open_repo_with_git_cli(&resolved_path).map_err(|cli_err| {
|
||||
let diagnosis = diagnose_repo_issue(&resolved_path);
|
||||
anyhow::anyhow!(
|
||||
"Failed to open git repository:\n\
|
||||
\n\
|
||||
=== git2 Error ===\n {}\n\
|
||||
\n\
|
||||
@@ -195,17 +193,20 @@ impl GitRepo {
|
||||
2. Run: git status (to verify git works)\n\
|
||||
3. Run: git config --global --add safe.directory \"*\"\n\
|
||||
4. Check file permissions",
|
||||
git2_err, cli_err, diagnosis
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
let repo_path = repo.workdir()
|
||||
git2_err,
|
||||
cli_err,
|
||||
diagnosis
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
let repo_path = repo
|
||||
.workdir()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| resolved_path.clone());
|
||||
|
||||
|
||||
let config = repo.config().ok();
|
||||
|
||||
|
||||
Ok(Self {
|
||||
repo,
|
||||
path: normalize_path_for_git2(&repo_path),
|
||||
@@ -246,7 +247,11 @@ impl GitRepo {
|
||||
pub fn get_user_name(&self) -> Result<String> {
|
||||
self.get_config("user.name")?
|
||||
.or_else(|| std::env::var("GIT_AUTHOR_NAME").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("User name not configured. Set it with: git config user.name \"Your Name\""))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"User name not configured. Set it with: git config user.name \"Your Name\""
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the configured user email
|
||||
@@ -258,7 +263,8 @@ impl GitRepo {
|
||||
|
||||
/// Get the configured GPG signing key
|
||||
pub fn get_signing_key(&self) -> Result<Option<String>> {
|
||||
Ok(self.get_config("user.signingkey")?
|
||||
Ok(self
|
||||
.get_config("user.signingkey")?
|
||||
.or_else(|| std::env::var("GIT_SIGNING_KEY").ok()))
|
||||
}
|
||||
|
||||
@@ -285,13 +291,9 @@ impl GitRepo {
|
||||
if let Some(program) = self.get_config("gpg.program")? {
|
||||
return Ok(program);
|
||||
}
|
||||
|
||||
let default_gpg = if cfg!(windows) {
|
||||
"gpg.exe"
|
||||
} else {
|
||||
"gpg"
|
||||
};
|
||||
|
||||
|
||||
let default_gpg = if cfg!(windows) { "gpg.exe" } else { "gpg" };
|
||||
|
||||
Ok(default_gpg.to_string())
|
||||
}
|
||||
|
||||
@@ -299,10 +301,13 @@ impl GitRepo {
|
||||
pub fn create_signature(&self) -> Result<Signature<'_>> {
|
||||
let name = self.get_user_name()?;
|
||||
let email = self.get_user_email()?;
|
||||
let time = git2::Time::new(std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64, 0);
|
||||
let time = git2::Time::new(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64,
|
||||
0,
|
||||
);
|
||||
Signature::new(&name, &email, &time).map_err(Into::into)
|
||||
}
|
||||
|
||||
@@ -346,7 +351,7 @@ impl GitRepo {
|
||||
/// then lock files like Cargo.lock
|
||||
pub fn get_staged_diff_sorted(&self) -> Result<String> {
|
||||
let diff = self.get_staged_diff()?;
|
||||
|
||||
|
||||
if diff.is_empty() {
|
||||
return Ok(diff);
|
||||
}
|
||||
@@ -382,9 +387,7 @@ impl GitRepo {
|
||||
});
|
||||
|
||||
// Combine sorted diffs
|
||||
let sorted_diff: String = file_diffs.into_iter()
|
||||
.map(|(_, diff)| diff)
|
||||
.collect();
|
||||
let sorted_diff: String = file_diffs.into_iter().map(|(_, diff)| diff).collect();
|
||||
|
||||
Ok(sorted_diff)
|
||||
}
|
||||
@@ -412,20 +415,31 @@ fn extract_file_from_diff_line(line: &str) -> String {
|
||||
fn file_importance_score(filename: &str) -> i32 {
|
||||
// Priority list for important file types
|
||||
let important_extensions = [
|
||||
".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".java", ".cpp", ".c", ".rust",
|
||||
".vue", ".svelte", ".html", ".css", ".scss", ".sass", ".less",
|
||||
".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".java", ".cpp", ".c", ".rust", ".vue",
|
||||
".svelte", ".html", ".css", ".scss", ".sass", ".less",
|
||||
];
|
||||
|
||||
|
||||
// Config files that are important but less than source code
|
||||
let config_files = [
|
||||
"Cargo.toml", "package.json", "go.mod", "go.sum", "pom.xml",
|
||||
"Makefile", "CMakeLists.txt", "build.gradle", "gradle.properties",
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"pom.xml",
|
||||
"Makefile",
|
||||
"CMakeLists.txt",
|
||||
"build.gradle",
|
||||
"gradle.properties",
|
||||
];
|
||||
|
||||
|
||||
// Lock files - lowest priority
|
||||
let lock_files = [
|
||||
"Cargo.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
||||
"Gemfile.lock", "composer.lock",
|
||||
"Cargo.lock",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"Gemfile.lock",
|
||||
"composer.lock",
|
||||
];
|
||||
|
||||
// Check lock files first (lowest priority)
|
||||
@@ -498,18 +512,22 @@ impl GitRepo {
|
||||
|
||||
/// Get list of staged files
|
||||
pub fn get_staged_files(&self) -> Result<Vec<String>> {
|
||||
let statuses = self.repo.statuses(Some(
|
||||
StatusOptions::new()
|
||||
.include_untracked(false),
|
||||
))?;
|
||||
let statuses = self
|
||||
.repo
|
||||
.statuses(Some(StatusOptions::new().include_untracked(false)))?;
|
||||
|
||||
let mut files = vec![];
|
||||
for entry in statuses.iter() {
|
||||
let status = entry.status();
|
||||
if (status.is_index_new() || status.is_index_modified() || status.is_index_deleted() || status.is_index_renamed() || status.is_index_typechange())
|
||||
&& let Some(path) = entry.path() {
|
||||
files.push(path.to_string());
|
||||
}
|
||||
if (status.is_index_new()
|
||||
|| status.is_index_modified()
|
||||
|| status.is_index_deleted()
|
||||
|| status.is_index_renamed()
|
||||
|| status.is_index_typechange())
|
||||
&& let Some(path) = entry.path()
|
||||
{
|
||||
files.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
@@ -629,20 +647,21 @@ impl GitRepo {
|
||||
if !output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
|
||||
let error_msg = if stderr.is_empty() {
|
||||
if stdout.is_empty() {
|
||||
"GPG signing failed. Please check:\n\
|
||||
1. GPG signing key is configured (git config --get user.signingkey)\n\
|
||||
2. GPG agent is running\n\
|
||||
3. You can sign commits manually (try: git commit -S -m 'test')".to_string()
|
||||
3. You can sign commits manually (try: git commit -S -m 'test')"
|
||||
.to_string()
|
||||
} else {
|
||||
stdout.to_string()
|
||||
}
|
||||
} else {
|
||||
stderr.to_string()
|
||||
};
|
||||
|
||||
|
||||
bail!("Failed to create signed commit: {}", error_msg);
|
||||
}
|
||||
|
||||
@@ -655,7 +674,8 @@ impl GitRepo {
|
||||
let head = self.repo.head()?;
|
||||
|
||||
if head.is_branch() {
|
||||
let name = head.shorthand()
|
||||
let name = head
|
||||
.shorthand()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?;
|
||||
Ok(name.to_string())
|
||||
} else {
|
||||
@@ -666,7 +686,8 @@ impl GitRepo {
|
||||
/// Get current commit hash (short)
|
||||
pub fn current_commit_short(&self) -> Result<String> {
|
||||
let head = self.repo.head()?;
|
||||
let oid = head.target()
|
||||
let oid = head
|
||||
.target()
|
||||
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
|
||||
Ok(oid.to_string()[..8].to_string())
|
||||
}
|
||||
@@ -674,7 +695,8 @@ impl GitRepo {
|
||||
/// Get current commit hash (full)
|
||||
pub fn current_commit(&self) -> Result<String> {
|
||||
let head = self.repo.head()?;
|
||||
let oid = head.target()
|
||||
let oid = head
|
||||
.target()
|
||||
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
|
||||
Ok(oid.to_string())
|
||||
}
|
||||
@@ -773,13 +795,7 @@ impl GitRepo {
|
||||
if sign {
|
||||
self.create_signed_tag_with_git2(name, msg, &sig, target.id())?;
|
||||
} else {
|
||||
self.repo.tag(
|
||||
name,
|
||||
target.as_object(),
|
||||
&sig,
|
||||
msg,
|
||||
false,
|
||||
)?;
|
||||
self.repo.tag(name, target.as_object(), &sig, msg, false)?;
|
||||
}
|
||||
} else {
|
||||
self.repo.tag(
|
||||
@@ -795,7 +811,13 @@ impl GitRepo {
|
||||
}
|
||||
|
||||
/// Create signed tag using git CLI
|
||||
fn create_signed_tag_with_git2(&self, name: &str, message: &str, _signature: &Signature, _target_id: Oid) -> Result<()> {
|
||||
fn create_signed_tag_with_git2(
|
||||
&self,
|
||||
name: &str,
|
||||
message: &str,
|
||||
_signature: &Signature,
|
||||
_target_id: Oid,
|
||||
) -> Result<()> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["tag", "-s", name, "-m", message])
|
||||
.current_dir(&self.path)
|
||||
@@ -810,7 +832,12 @@ impl GitRepo {
|
||||
}
|
||||
|
||||
/// Create GPG signature for arbitrary content
|
||||
fn create_gpg_signature_for_content(&self, _content: &str, _gpg_program: &str, _signing_key: &str) -> Result<String> {
|
||||
fn create_gpg_signature_for_content(
|
||||
&self,
|
||||
_content: &str,
|
||||
_gpg_program: &str,
|
||||
_signing_key: &str,
|
||||
) -> Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
@@ -838,7 +865,8 @@ impl GitRepo {
|
||||
/// Get remote URL
|
||||
pub fn get_remote_url(&self, remote: &str) -> Result<String> {
|
||||
let remote_obj = self.repo.find_remote(remote)?;
|
||||
let url = remote_obj.url()
|
||||
let url = remote_obj
|
||||
.url()
|
||||
.ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?;
|
||||
Ok(url.to_string())
|
||||
}
|
||||
@@ -889,9 +917,10 @@ impl GitRepo {
|
||||
}
|
||||
|
||||
// Conflicted files (both columns are U or DD, AA, etc.)
|
||||
if (index_status == 'U' || worktree_status == 'U') ||
|
||||
(index_status == 'A' && worktree_status == 'A') ||
|
||||
(index_status == 'D' && worktree_status == 'D') {
|
||||
if (index_status == 'U' || worktree_status == 'U')
|
||||
|| (index_status == 'A' && worktree_status == 'A')
|
||||
|| (index_status == 'D' && worktree_status == 'D')
|
||||
{
|
||||
conflicted += 1;
|
||||
}
|
||||
}
|
||||
@@ -982,49 +1011,51 @@ impl StatusSummary {
|
||||
|
||||
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
|
||||
let start_path = start_path.as_ref();
|
||||
|
||||
|
||||
let absolute_start = get_absolute_path(start_path)?;
|
||||
let resolved_start = resolve_path_without_canonicalize(&absolute_start);
|
||||
|
||||
|
||||
if let Ok(repo) = GitRepo::open(&resolved_start) {
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
|
||||
let mut current = resolved_start.as_path();
|
||||
let mut attempted_paths = vec![current.to_string_lossy().to_string()];
|
||||
|
||||
|
||||
let max_depth = 50;
|
||||
let mut depth = 0;
|
||||
|
||||
|
||||
while let Some(parent) = current.parent() {
|
||||
depth += 1;
|
||||
if depth > max_depth {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
attempted_paths.push(parent.to_string_lossy().to_string());
|
||||
|
||||
|
||||
if let Ok(repo) = GitRepo::open(parent) {
|
||||
return Ok(repo);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
|
||||
if let Ok(output) = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--show-toplevel"])
|
||||
.current_dir(&resolved_start)
|
||||
.output()
|
||||
&& output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let git_root = stdout.trim();
|
||||
if !git_root.is_empty()
|
||||
&& let Ok(repo) = GitRepo::open(git_root) {
|
||||
return Ok(repo);
|
||||
}
|
||||
&& output.status.success()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let git_root = stdout.trim();
|
||||
if !git_root.is_empty()
|
||||
&& let Ok(repo) = GitRepo::open(git_root)
|
||||
{
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let diagnosis = diagnose_repo_issue(&resolved_start);
|
||||
|
||||
|
||||
bail!(
|
||||
"No git repository found.\n\
|
||||
\n\
|
||||
@@ -1238,7 +1269,10 @@ impl MergedUserConfig {
|
||||
}
|
||||
|
||||
pub fn has_local_overrides(&self) -> bool {
|
||||
self.name.is_local() || self.email.is_local() || self.signing_key.is_local() || self.ssh_command.is_local()
|
||||
self.name.is_local()
|
||||
|| self.email.is_local()
|
||||
|| self.signing_key.is_local()
|
||||
|| self.ssh_command.is_local()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1265,23 +1299,38 @@ impl UserConfig {
|
||||
diffs.push(ConfigDiff {
|
||||
key: "user.name".to_string(),
|
||||
left: self.name.clone().unwrap_or_else(|| "<not set>".to_string()),
|
||||
right: other.name.clone().unwrap_or_else(|| "<not set>".to_string()),
|
||||
right: other
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "<not set>".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if self.email != other.email {
|
||||
diffs.push(ConfigDiff {
|
||||
key: "user.email".to_string(),
|
||||
left: self.email.clone().unwrap_or_else(|| "<not set>".to_string()),
|
||||
right: other.email.clone().unwrap_or_else(|| "<not set>".to_string()),
|
||||
left: self
|
||||
.email
|
||||
.clone()
|
||||
.unwrap_or_else(|| "<not set>".to_string()),
|
||||
right: other
|
||||
.email
|
||||
.clone()
|
||||
.unwrap_or_else(|| "<not set>".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if self.signing_key != other.signing_key {
|
||||
diffs.push(ConfigDiff {
|
||||
key: "user.signingkey".to_string(),
|
||||
left: self.signing_key.clone().unwrap_or_else(|| "<not set>".to_string()),
|
||||
right: other.signing_key.clone().unwrap_or_else(|| "<not set>".to_string()),
|
||||
left: self
|
||||
.signing_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| "<not set>".to_string()),
|
||||
right: other
|
||||
.signing_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| "<not set>".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::GitRepo;
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{Result, bail};
|
||||
use semver::Version;
|
||||
|
||||
/// Tag builder for creating tags
|
||||
@@ -69,19 +69,19 @@ impl TagBuilder {
|
||||
|
||||
/// Build tag message
|
||||
pub fn build_message(&self) -> Result<String> {
|
||||
let message = self.message.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
let name = self.name.as_deref().unwrap_or("unknown");
|
||||
format!("Release {}", name)
|
||||
});
|
||||
|
||||
let message = self.message.as_ref().cloned().unwrap_or_else(|| {
|
||||
let name = self.name.as_deref().unwrap_or("unknown");
|
||||
format!("Release {}", name)
|
||||
});
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Execute tag creation
|
||||
pub fn execute(&self, repo: &GitRepo) -> Result<()> {
|
||||
let name = self.name.as_ref()
|
||||
let name = self
|
||||
.name
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Tag name is required"))?;
|
||||
|
||||
if !self.force {
|
||||
@@ -105,10 +105,10 @@ impl TagBuilder {
|
||||
/// Execute and push tag
|
||||
pub fn execute_and_push(&self, repo: &GitRepo, remote: &str) -> Result<()> {
|
||||
self.execute(repo)?;
|
||||
|
||||
|
||||
let name = self.name.as_ref().unwrap();
|
||||
repo.push(remote, &format!("refs/tags/{}", name))?;
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,10 @@ impl VersionBump {
|
||||
"minor" => Ok(Self::Minor),
|
||||
"patch" => Ok(Self::Patch),
|
||||
"prerelease" | "pre" => Ok(Self::Prerelease),
|
||||
_ => bail!("Invalid version bump: {}. Use: major, minor, patch, prerelease", s),
|
||||
_ => bail!(
|
||||
"Invalid version bump: {}. Use: major, minor, patch, prerelease",
|
||||
s
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +152,7 @@ impl VersionBump {
|
||||
/// Get latest version tag from repository
|
||||
pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result<Option<Version>> {
|
||||
let tags = repo.get_tags()?;
|
||||
|
||||
|
||||
let mut versions: Vec<Version> = tags
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
@@ -158,9 +161,9 @@ pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result<Option<Version
|
||||
Version::parse(version_str).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
versions.sort_by(|a, b| b.cmp(a)); // Descending order
|
||||
|
||||
|
||||
Ok(versions.into_iter().next())
|
||||
}
|
||||
|
||||
@@ -183,14 +186,17 @@ pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump {
|
||||
let mut has_breaking = false;
|
||||
let mut has_feature = false;
|
||||
let mut has_fix = false;
|
||||
|
||||
|
||||
for commit in commits {
|
||||
let msg = commit.message.to_lowercase();
|
||||
|
||||
if msg.contains("breaking change") || msg.contains("breaking-change") || msg.contains("breaking_change") {
|
||||
|
||||
if msg.contains("breaking change")
|
||||
|| msg.contains("breaking-change")
|
||||
|| msg.contains("breaking_change")
|
||||
{
|
||||
has_breaking = true;
|
||||
}
|
||||
|
||||
|
||||
if let Some(commit_type) = commit.commit_type() {
|
||||
match commit_type.as_str() {
|
||||
"feat" => has_feature = true,
|
||||
@@ -199,7 +205,7 @@ pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if has_breaking {
|
||||
VersionBump::Major
|
||||
} else if has_feature {
|
||||
@@ -214,20 +220,20 @@ pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump {
|
||||
/// Generate tag message from commits
|
||||
pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> String {
|
||||
let mut message = format!("Release {}\n\n", version);
|
||||
|
||||
|
||||
// Group commits by type
|
||||
let mut features = vec![];
|
||||
let mut fixes = vec![];
|
||||
let mut other = vec![];
|
||||
let mut breaking = vec![];
|
||||
|
||||
|
||||
for commit in commits {
|
||||
let subject = commit.subject();
|
||||
|
||||
|
||||
if commit.message.contains("BREAKING CHANGE") {
|
||||
breaking.push(subject.to_string());
|
||||
}
|
||||
|
||||
|
||||
if let Some(commit_type) = commit.commit_type() {
|
||||
match commit_type.as_str() {
|
||||
"feat" => features.push(subject.to_string()),
|
||||
@@ -238,7 +244,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str
|
||||
other.push(subject.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Build message
|
||||
if !breaking.is_empty() {
|
||||
message.push_str("## Breaking Changes\n\n");
|
||||
@@ -247,7 +253,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str
|
||||
}
|
||||
message.push('\n');
|
||||
}
|
||||
|
||||
|
||||
if !features.is_empty() {
|
||||
message.push_str("## Features\n\n");
|
||||
for item in &features {
|
||||
@@ -255,7 +261,7 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str
|
||||
}
|
||||
message.push('\n');
|
||||
}
|
||||
|
||||
|
||||
if !fixes.is_empty() {
|
||||
message.push_str("## Bug Fixes\n\n");
|
||||
for item in &fixes {
|
||||
@@ -263,36 +269,36 @@ pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> Str
|
||||
}
|
||||
message.push('\n');
|
||||
}
|
||||
|
||||
|
||||
if !other.is_empty() {
|
||||
message.push_str("## Other Changes\n\n");
|
||||
for item in &other {
|
||||
message.push_str(&format!("- {}\n", item));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
/// Tag deletion helper
|
||||
pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()> {
|
||||
repo.delete_tag(name)?;
|
||||
|
||||
|
||||
if let Some(remote) = remote {
|
||||
use std::process::Command;
|
||||
|
||||
|
||||
let refspec = format!(":refs/tags/{}", name);
|
||||
let output = Command::new("git")
|
||||
.args(["push", remote, &refspec])
|
||||
.current_dir(repo.path())
|
||||
.output()?;
|
||||
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to delete remote tag: {}", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -303,7 +309,7 @@ pub fn list_tags(
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<super::TagInfo>> {
|
||||
let tags = repo.get_tags()?;
|
||||
|
||||
|
||||
let filtered: Vec<_> = tags
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
@@ -314,7 +320,7 @@ pub fn list_tags(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
if let Some(limit) = limit {
|
||||
Ok(filtered.into_iter().take(limit).collect())
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user