style: 格式化代码并优化导入顺序

This commit is contained in:
2026-05-27 15:15:15 +08:00
parent b8182e7538
commit 90074e6e32
34 changed files with 2931 additions and 1648 deletions

View File

@@ -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 {

View File

@@ -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(

View File

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

View File

@@ -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 {