feat:(first commit)created repository and complete 0.1.0
This commit is contained in:
480
src/git/changelog.rs
Normal file
480
src/git/changelog.rs
Normal file
@@ -0,0 +1,480 @@
|
||||
use super::{CommitInfo, GitRepo};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Changelog generator
|
||||
pub struct ChangelogGenerator {
|
||||
format: ChangelogFormat,
|
||||
include_hashes: bool,
|
||||
include_authors: bool,
|
||||
group_by_type: bool,
|
||||
custom_categories: Vec<ChangelogCategory>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChangelogFormat {
|
||||
KeepAChangelog,
|
||||
GitHubReleases,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangelogCategory {
|
||||
pub title: String,
|
||||
pub types: Vec<String>,
|
||||
}
|
||||
|
||||
impl ChangelogGenerator {
|
||||
/// Create new changelog generator
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
format: ChangelogFormat::KeepAChangelog,
|
||||
include_hashes: false,
|
||||
include_authors: false,
|
||||
group_by_type: true,
|
||||
custom_categories: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Set format
|
||||
pub fn format(mut self, format: ChangelogFormat) -> Self {
|
||||
self.format = format;
|
||||
self
|
||||
}
|
||||
|
||||
/// Include commit hashes
|
||||
pub fn include_hashes(mut self, include: bool) -> Self {
|
||||
self.include_hashes = include;
|
||||
self
|
||||
}
|
||||
|
||||
/// Include authors
|
||||
pub fn include_authors(mut self, include: bool) -> Self {
|
||||
self.include_authors = include;
|
||||
self
|
||||
}
|
||||
|
||||
/// Group by type
|
||||
pub fn group_by_type(mut self, group: bool) -> Self {
|
||||
self.group_by_type = group;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add custom category
|
||||
pub fn add_category(mut self, title: impl Into<String>, types: Vec<String>) -> Self {
|
||||
self.custom_categories.push(ChangelogCategory {
|
||||
title: title.into(),
|
||||
types,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Generate changelog for version
|
||||
pub fn generate(
|
||||
&self,
|
||||
version: &str,
|
||||
date: DateTime<Utc>,
|
||||
commits: &[CommitInfo],
|
||||
) -> Result<String> {
|
||||
match self.format {
|
||||
ChangelogFormat::KeepAChangelog => {
|
||||
self.generate_keep_a_changelog(version, date, commits)
|
||||
}
|
||||
ChangelogFormat::GitHubReleases => {
|
||||
self.generate_github_releases(version, date, commits)
|
||||
}
|
||||
ChangelogFormat::Custom => {
|
||||
self.generate_custom(version, date, commits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate changelog entry and prepend to file
|
||||
pub fn generate_and_prepend(
|
||||
&self,
|
||||
changelog_path: &Path,
|
||||
version: &str,
|
||||
date: DateTime<Utc>,
|
||||
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\n\n{}", entry)
|
||||
} else {
|
||||
// Find position after header
|
||||
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 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let header = lines[..header_end].join("\n");
|
||||
let rest = lines[header_end..].join("\n");
|
||||
|
||||
format!("{}\n{}\n{}", header, entry, rest)
|
||||
};
|
||||
|
||||
fs::write(changelog_path, new_content)
|
||||
.with_context(|| format!("Failed to write changelog: {:?}", changelog_path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_keep_a_changelog(
|
||||
&self,
|
||||
version: &str,
|
||||
date: DateTime<Utc>,
|
||||
commits: &[CommitInfo],
|
||||
) -> 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"]),
|
||||
("Changed", vec!["refactor", "perf"]),
|
||||
("Deprecated", vec![]),
|
||||
("Removed", vec!["remove"]),
|
||||
("Fixed", vec!["fix"]),
|
||||
("Security", vec!["security"]),
|
||||
];
|
||||
|
||||
for (title, types) in &categories {
|
||||
let items: Vec<&CommitInfo> = commits
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
if let Some(ref t) = c.commit_type() {
|
||||
types.contains(&t.as_str())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !items.is_empty() {
|
||||
output.push_str(&format!("### {}\n\n", title));
|
||||
for commit in items {
|
||||
output.push_str(&self.format_commit(commit));
|
||||
output.push('\n');
|
||||
}
|
||||
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| {
|
||||
if let Some(ref t) = c.commit_type() {
|
||||
!categorized.contains(t)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !other.is_empty() {
|
||||
output.push_str("### Other\n\n");
|
||||
for commit in other {
|
||||
output.push_str(&self.format_commit(commit));
|
||||
output.push('\n');
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
} else {
|
||||
for commit in commits {
|
||||
output.push_str(&self.format_commit(commit));
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn generate_github_releases(
|
||||
&self,
|
||||
version: &str,
|
||||
_date: DateTime<Utc>,
|
||||
commits: &[CommitInfo],
|
||||
) -> Result<String> {
|
||||
let mut output = format!("## What's Changed\n\n");
|
||||
|
||||
// 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 {
|
||||
let msg = commit.subject();
|
||||
|
||||
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),
|
||||
"fix" => fixes.push(commit),
|
||||
"docs" => docs.push(commit),
|
||||
_ => other.push(commit),
|
||||
}
|
||||
} else {
|
||||
other.push(commit);
|
||||
}
|
||||
}
|
||||
|
||||
if !breaking.is_empty() {
|
||||
output.push_str("### ⚠ Breaking Changes\n\n");
|
||||
for commit in breaking {
|
||||
output.push_str(&self.format_commit_github(commit));
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
if !features.is_empty() {
|
||||
output.push_str("### 🚀 Features\n\n");
|
||||
for commit in features {
|
||||
output.push_str(&self.format_commit_github(commit));
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
if !fixes.is_empty() {
|
||||
output.push_str("### 🐛 Bug Fixes\n\n");
|
||||
for commit in fixes {
|
||||
output.push_str(&self.format_commit_github(commit));
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
if !docs.is_empty() {
|
||||
output.push_str("### 📚 Documentation\n\n");
|
||||
for commit in docs {
|
||||
output.push_str(&self.format_commit_github(commit));
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
fn generate_custom(
|
||||
&self,
|
||||
version: &str,
|
||||
date: DateTime<Utc>,
|
||||
commits: &[CommitInfo],
|
||||
) -> Result<String> {
|
||||
// Use custom categories if defined
|
||||
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()
|
||||
.filter(|c| {
|
||||
if let Some(ref t) = c.commit_type() {
|
||||
category.types.contains(t)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !items.is_empty() {
|
||||
output.push_str(&format!("### {}\n\n", category.title));
|
||||
for commit in items {
|
||||
output.push_str(&self.format_commit(commit));
|
||||
output.push('\n');
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
} else {
|
||||
// Fall back to keep-a-changelog
|
||||
self.generate_keep_a_changelog(version, date, commits)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChangelogGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read existing changelog
|
||||
pub fn read_changelog(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read changelog: {:?}", path))
|
||||
}
|
||||
|
||||
/// Initialize new changelog file
|
||||
pub fn init_changelog(path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
anyhow::bail!("Changelog already exists at {:?}", path);
|
||||
}
|
||||
|
||||
let content = r#"# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
"#;
|
||||
|
||||
fs::write(path, content)
|
||||
.with_context(|| format!("Failed to create changelog: {:?}", path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate changelog from git history
|
||||
pub fn generate_from_history(
|
||||
repo: &GitRepo,
|
||||
from_tag: Option<&str>,
|
||||
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 {
|
||||
// Get last 50 commits if no tag specified
|
||||
repo.get_commits(50)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update version links in changelog
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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("## [") {
|
||||
if let Some(start) = line.find('[') {
|
||||
if let Some(end) = line.find(']') {
|
||||
let version = &line[start + 1..end];
|
||||
if version != "Unreleased" {
|
||||
if 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 {
|
||||
repo.get_commits(50)
|
||||
}
|
||||
}
|
||||
|
||||
/// Changelog entry for a specific version
|
||||
pub struct ChangelogEntry {
|
||||
pub version: String,
|
||||
pub date: DateTime<Utc>,
|
||||
pub commits: Vec<CommitInfo>,
|
||||
}
|
||||
|
||||
impl ChangelogEntry {
|
||||
/// Create new entry
|
||||
pub fn new(version: impl Into<String>, commits: Vec<CommitInfo>) -> Self {
|
||||
Self {
|
||||
version: version.into(),
|
||||
date: Utc::now(),
|
||||
commits,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set date
|
||||
pub fn with_date(mut self, date: DateTime<Utc>) -> Self {
|
||||
self.date = date;
|
||||
self
|
||||
}
|
||||
}
|
||||
367
src/git/commit.rs
Normal file
367
src/git/commit.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
use super::GitRepo;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::Local;
|
||||
|
||||
/// Commit builder for creating commits
|
||||
pub struct CommitBuilder {
|
||||
commit_type: Option<String>,
|
||||
scope: Option<String>,
|
||||
description: Option<String>,
|
||||
body: Option<String>,
|
||||
footer: Option<String>,
|
||||
breaking: bool,
|
||||
sign: bool,
|
||||
amend: bool,
|
||||
no_verify: bool,
|
||||
dry_run: bool,
|
||||
format: crate::config::CommitFormat,
|
||||
}
|
||||
|
||||
impl CommitBuilder {
|
||||
/// Create new commit builder
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
commit_type: None,
|
||||
scope: None,
|
||||
description: None,
|
||||
body: None,
|
||||
footer: None,
|
||||
breaking: false,
|
||||
sign: false,
|
||||
amend: false,
|
||||
no_verify: false,
|
||||
dry_run: false,
|
||||
format: crate::config::CommitFormat::Conventional,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set commit type
|
||||
pub fn commit_type(mut self, commit_type: impl Into<String>) -> Self {
|
||||
self.commit_type = Some(commit_type.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set scope
|
||||
pub fn scope(mut self, scope: impl Into<String>) -> Self {
|
||||
self.scope = Some(scope.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set description
|
||||
pub fn description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set body
|
||||
pub fn body(mut self, body: impl Into<String>) -> Self {
|
||||
self.body = Some(body.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set footer
|
||||
pub fn footer(mut self, footer: impl Into<String>) -> Self {
|
||||
self.footer = Some(footer.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Mark as breaking change
|
||||
pub fn breaking(mut self, breaking: bool) -> Self {
|
||||
self.breaking = breaking;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sign the commit
|
||||
pub fn sign(mut self, sign: bool) -> Self {
|
||||
self.sign = sign;
|
||||
self
|
||||
}
|
||||
|
||||
/// Amend previous commit
|
||||
pub fn amend(mut self, amend: bool) -> Self {
|
||||
self.amend = amend;
|
||||
self
|
||||
}
|
||||
|
||||
/// Skip pre-commit hooks
|
||||
pub fn no_verify(mut self, no_verify: bool) -> Self {
|
||||
self.no_verify = no_verify;
|
||||
self
|
||||
}
|
||||
|
||||
/// Dry run (don't actually commit)
|
||||
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
||||
self.dry_run = dry_run;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set commit format
|
||||
pub fn format(mut self, format: crate::config::CommitFormat) -> Self {
|
||||
self.format = format;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build commit message
|
||||
pub fn build_message(&self) -> Result<String> {
|
||||
let commit_type = self.commit_type.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Commit type is required"))?;
|
||||
|
||||
let description = self.description.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Description is required"))?;
|
||||
|
||||
let message = match self.format {
|
||||
crate::config::CommitFormat::Conventional => {
|
||||
crate::utils::formatter::format_conventional_commit(
|
||||
commit_type,
|
||||
self.scope.as_deref(),
|
||||
description,
|
||||
self.body.as_deref(),
|
||||
self.footer.as_deref(),
|
||||
self.breaking,
|
||||
)
|
||||
}
|
||||
crate::config::CommitFormat::Commitlint => {
|
||||
crate::utils::formatter::format_commitlint_commit(
|
||||
commit_type,
|
||||
self.scope.as_deref(),
|
||||
description,
|
||||
self.body.as_deref(),
|
||||
self.footer.as_deref(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Execute commit
|
||||
pub fn execute(&self, repo: &GitRepo) -> Result<Option<String>> {
|
||||
let message = self.build_message()?;
|
||||
|
||||
if self.dry_run {
|
||||
return Ok(Some(message));
|
||||
}
|
||||
|
||||
// Check if there are staged changes
|
||||
let staged_files = repo.get_staged_files()?;
|
||||
if staged_files.is_empty() && !self.amend {
|
||||
bail!("No staged changes to commit. Use 'git add' to stage files first.");
|
||||
}
|
||||
|
||||
// Validate message
|
||||
match self.format {
|
||||
crate::config::CommitFormat::Conventional => {
|
||||
crate::utils::validators::validate_conventional_commit(&message)?;
|
||||
}
|
||||
crate::config::CommitFormat::Commitlint => {
|
||||
crate::utils::validators::validate_commitlint_commit(&message)?;
|
||||
}
|
||||
}
|
||||
|
||||
if self.amend {
|
||||
self.amend_commit(repo, &message)?;
|
||||
} else {
|
||||
repo.commit(&message, self.sign)?;
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
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 stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to amend commit: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommitBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a date-based commit message
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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") {
|
||||
suggestions.push("chore");
|
||||
}
|
||||
|
||||
// Check for dependencies
|
||||
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];
|
||||
let s = &type_part[open + 1..close];
|
||||
(Some(t.to_string()), Some(s.to_string()))
|
||||
} else {
|
||||
(Some(type_part.to_string()), None)
|
||||
}
|
||||
} 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:") {
|
||||
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")) },
|
||||
breaking,
|
||||
};
|
||||
}
|
||||
|
||||
// Non-conventional commit
|
||||
ParsedCommit {
|
||||
description: Some(first_line.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed commit structure
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ParsedCommit {
|
||||
pub commit_type: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub footer: Option<String>,
|
||||
pub breaking: bool,
|
||||
}
|
||||
|
||||
impl ParsedCommit {
|
||||
/// Convert back to commit message
|
||||
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(
|
||||
commit_type,
|
||||
self.scope.as_deref(),
|
||||
description,
|
||||
self.body.as_deref(),
|
||||
self.footer.as_deref(),
|
||||
self.breaking,
|
||||
)
|
||||
}
|
||||
crate::config::CommitFormat::Commitlint => {
|
||||
crate::utils::formatter::format_commitlint_commit(
|
||||
commit_type,
|
||||
self.scope.as_deref(),
|
||||
description,
|
||||
self.body.as_deref(),
|
||||
self.footer.as_deref(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
590
src/git/mod.rs
Normal file
590
src/git/mod.rs
Normal file
@@ -0,0 +1,590 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use git2::{Repository, Signature, StatusOptions, DiffOptions};
|
||||
use std::path::Path;
|
||||
|
||||
pub mod changelog;
|
||||
pub mod commit;
|
||||
pub mod tag;
|
||||
|
||||
pub use changelog::ChangelogGenerator;
|
||||
pub use commit::CommitBuilder;
|
||||
pub use tag::TagBuilder;
|
||||
|
||||
/// Git repository wrapper
|
||||
pub struct GitRepo {
|
||||
repo: Repository,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl GitRepo {
|
||||
/// Open a git repository
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let path = path.as_ref().canonicalize()
|
||||
.unwrap_or_else(|_| path.as_ref().to_path_buf());
|
||||
|
||||
let repo = Repository::open(&path)
|
||||
.with_context(|| format!("Failed to open git repository: {:?}", path))?;
|
||||
|
||||
Ok(Self { repo, path })
|
||||
}
|
||||
|
||||
/// Get repository path
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Get internal git2 repository
|
||||
pub fn inner(&self) -> &Repository {
|
||||
&self.repo
|
||||
}
|
||||
|
||||
/// Check if this is a valid git repository
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.repo.is_bare()
|
||||
}
|
||||
|
||||
/// Check if there are uncommitted changes
|
||||
pub fn has_changes(&self) -> Result<bool> {
|
||||
let statuses = self.repo.statuses(Some(
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.renames_head_to_index(true)
|
||||
.renames_index_to_workdir(true),
|
||||
))?;
|
||||
|
||||
Ok(!statuses.is_empty())
|
||||
}
|
||||
|
||||
/// Get staged diff
|
||||
pub fn get_staged_diff(&self) -> Result<String> {
|
||||
let head = self.repo.head().ok();
|
||||
let head_tree = head.as_ref()
|
||||
.and_then(|h| h.peel_to_tree().ok());
|
||||
|
||||
let mut index = self.repo.index()?;
|
||||
let index_tree = index.write_tree()?;
|
||||
let index_tree = self.repo.find_tree(index_tree)?;
|
||||
|
||||
let diff = if let Some(head) = head_tree {
|
||||
self.repo.diff_tree_to_index(Some(&head), Some(&index), None)?
|
||||
} else {
|
||||
self.repo.diff_tree_to_index(None, Some(&index), None)?
|
||||
};
|
||||
|
||||
let mut diff_text = String::new();
|
||||
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
||||
if let Ok(content) = std::str::from_utf8(line.content()) {
|
||||
diff_text.push_str(content);
|
||||
}
|
||||
true
|
||||
})?;
|
||||
|
||||
Ok(diff_text)
|
||||
}
|
||||
|
||||
/// Get unstaged diff
|
||||
pub fn get_unstaged_diff(&self) -> Result<String> {
|
||||
let diff = self.repo.diff_index_to_workdir(None, None)?;
|
||||
|
||||
let mut diff_text = String::new();
|
||||
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
|
||||
if let Ok(content) = std::str::from_utf8(line.content()) {
|
||||
diff_text.push_str(content);
|
||||
}
|
||||
true
|
||||
})?;
|
||||
|
||||
Ok(diff_text)
|
||||
}
|
||||
|
||||
/// Get complete diff (staged + unstaged)
|
||||
pub fn get_full_diff(&self) -> Result<String> {
|
||||
let staged = self.get_staged_diff().unwrap_or_default();
|
||||
let unstaged = self.get_unstaged_diff().unwrap_or_default();
|
||||
|
||||
Ok(format!("{}{}", staged, unstaged))
|
||||
}
|
||||
|
||||
/// Get list of changed files
|
||||
pub fn get_changed_files(&self) -> Result<Vec<String>> {
|
||||
let statuses = self.repo.statuses(Some(
|
||||
StatusOptions::new()
|
||||
.include_untracked(true)
|
||||
.renames_head_to_index(true)
|
||||
.renames_index_to_workdir(true),
|
||||
))?;
|
||||
|
||||
let mut files = vec![];
|
||||
for entry in statuses.iter() {
|
||||
if let Some(path) = entry.path() {
|
||||
files.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// 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 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() {
|
||||
if let Some(path) = entry.path() {
|
||||
files.push(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Stage files
|
||||
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
|
||||
let mut index = self.repo.index()?;
|
||||
|
||||
for path in paths {
|
||||
index.add_path(path.as_ref())?;
|
||||
}
|
||||
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage all changes
|
||||
pub fn stage_all(&self) -> Result<()> {
|
||||
let mut index = self.repo.index()?;
|
||||
|
||||
// Get list of all files in working directory
|
||||
let mut paths = Vec::new();
|
||||
for entry in std::fs::read_dir(".")? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
paths.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
for path_buf in paths {
|
||||
if let Ok(_) = index.add_path(&path_buf) {
|
||||
// File added successfully
|
||||
}
|
||||
}
|
||||
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unstage files
|
||||
pub fn unstage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
|
||||
let head = self.repo.head()?;
|
||||
let head_commit = head.peel_to_commit()?;
|
||||
let head_tree = head_commit.tree()?;
|
||||
|
||||
let mut index = self.repo.index()?;
|
||||
|
||||
for path in paths {
|
||||
// For now, just reset the index to HEAD
|
||||
// This removes all staged changes
|
||||
index.clear()?;
|
||||
}
|
||||
|
||||
index.write()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a commit
|
||||
pub fn commit(&self, message: &str, sign: bool) -> Result<git2::Oid> {
|
||||
let signature = self.repo.signature()?;
|
||||
let head = self.repo.head().ok();
|
||||
|
||||
let parents = if let Some(ref head) = head {
|
||||
vec![head.peel_to_commit()?]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
|
||||
|
||||
let oid = if sign {
|
||||
// For GPG signing, we need to use the command-line git
|
||||
self.commit_signed(message, &signature)?
|
||||
} else {
|
||||
let tree_id = self.repo.index()?.write_tree()?;
|
||||
let tree = self.repo.find_tree(tree_id)?;
|
||||
|
||||
self.repo.commit(
|
||||
Some("HEAD"),
|
||||
&signature,
|
||||
&signature,
|
||||
message,
|
||||
&tree,
|
||||
&parent_refs,
|
||||
)?
|
||||
};
|
||||
|
||||
Ok(oid)
|
||||
}
|
||||
|
||||
/// Create a signed commit using git command
|
||||
fn commit_signed(&self, message: &str, _signature: &git2::Signature) -> Result<git2::Oid> {
|
||||
use std::process::Command;
|
||||
|
||||
// Write message to temp file
|
||||
let temp_file = tempfile::NamedTempFile::new()?;
|
||||
std::fs::write(temp_file.path(), message)?;
|
||||
|
||||
// Use git CLI for signed commit
|
||||
let output = Command::new("git")
|
||||
.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
|
||||
.current_dir(&self.path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to create signed commit: {}", stderr);
|
||||
}
|
||||
|
||||
// Get the new HEAD
|
||||
let head = self.repo.head()?;
|
||||
Ok(head.target().unwrap())
|
||||
}
|
||||
|
||||
/// Get current branch name
|
||||
pub fn current_branch(&self) -> Result<String> {
|
||||
let head = self.repo.head()?;
|
||||
|
||||
if head.is_branch() {
|
||||
let name = head.shorthand()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?;
|
||||
Ok(name.to_string())
|
||||
} else {
|
||||
bail!("HEAD is not pointing to a branch")
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current commit hash (short)
|
||||
pub fn current_commit_short(&self) -> Result<String> {
|
||||
let head = self.repo.head()?;
|
||||
let oid = head.target()
|
||||
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
|
||||
Ok(oid.to_string()[..8].to_string())
|
||||
}
|
||||
|
||||
/// Get current commit hash (full)
|
||||
pub fn current_commit(&self) -> Result<String> {
|
||||
let head = self.repo.head()?;
|
||||
let oid = head.target()
|
||||
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
|
||||
Ok(oid.to_string())
|
||||
}
|
||||
|
||||
/// Get commit history
|
||||
pub fn get_commits(&self, count: usize) -> Result<Vec<CommitInfo>> {
|
||||
let mut revwalk = self.repo.revwalk()?;
|
||||
revwalk.push_head()?;
|
||||
|
||||
let mut commits = vec![];
|
||||
for (i, oid) in revwalk.enumerate() {
|
||||
if i >= count {
|
||||
break;
|
||||
}
|
||||
|
||||
let oid = oid?;
|
||||
let commit = self.repo.find_commit(oid)?;
|
||||
|
||||
commits.push(CommitInfo {
|
||||
id: oid.to_string(),
|
||||
short_id: oid.to_string()[..8].to_string(),
|
||||
message: commit.message().unwrap_or("").to_string(),
|
||||
author: commit.author().name().unwrap_or("").to_string(),
|
||||
email: commit.author().email().unwrap_or("").to_string(),
|
||||
time: commit.time().seconds(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// Get commits between two references
|
||||
pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<CommitInfo>> {
|
||||
let from_obj = self.repo.revparse_single(from)?;
|
||||
let to_obj = self.repo.revparse_single(to)?;
|
||||
|
||||
let from_commit = from_obj.peel_to_commit()?;
|
||||
let to_commit = to_obj.peel_to_commit()?;
|
||||
|
||||
let mut revwalk = self.repo.revwalk()?;
|
||||
revwalk.push(to_commit.id())?;
|
||||
revwalk.hide(from_commit.id())?;
|
||||
|
||||
let mut commits = vec![];
|
||||
for oid in revwalk {
|
||||
let oid = oid?;
|
||||
let commit = self.repo.find_commit(oid)?;
|
||||
|
||||
commits.push(CommitInfo {
|
||||
id: oid.to_string(),
|
||||
short_id: oid.to_string()[..8].to_string(),
|
||||
message: commit.message().unwrap_or("").to_string(),
|
||||
author: commit.author().name().unwrap_or("").to_string(),
|
||||
email: commit.author().email().unwrap_or("").to_string(),
|
||||
time: commit.time().seconds(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// Get tags
|
||||
pub fn get_tags(&self) -> Result<Vec<TagInfo>> {
|
||||
let mut tags = vec![];
|
||||
|
||||
self.repo.tag_foreach(|oid, name| {
|
||||
let name = String::from_utf8_lossy(name);
|
||||
let name = name.strip_prefix("refs/tags/").unwrap_or(&name);
|
||||
|
||||
if let Ok(commit) = self.repo.find_commit(oid) {
|
||||
tags.push(TagInfo {
|
||||
name: name.to_string(),
|
||||
target: oid.to_string(),
|
||||
message: commit.message().unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
true
|
||||
})?;
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Create a tag
|
||||
pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> {
|
||||
let head = self.repo.head()?;
|
||||
let target = head.peel_to_commit()?;
|
||||
|
||||
if let Some(msg) = message {
|
||||
// Annotated tag
|
||||
let sig = self.repo.signature()?;
|
||||
|
||||
if sign {
|
||||
// Use git CLI for signed tags
|
||||
self.create_signed_tag(name, msg)?;
|
||||
} else {
|
||||
self.repo.tag(
|
||||
name,
|
||||
target.as_object(),
|
||||
&sig,
|
||||
msg,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
// Lightweight tag
|
||||
self.repo.tag(
|
||||
name,
|
||||
target.as_object(),
|
||||
&self.repo.signature()?,
|
||||
"",
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create signed tag using git CLI
|
||||
fn create_signed_tag(&self, name: &str, message: &str) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&["tag", "-s", name, "-m", message])
|
||||
.current_dir(&self.path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Failed to create signed tag: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a tag
|
||||
pub fn delete_tag(&self, name: &str) -> Result<()> {
|
||||
self.repo.tag_delete(name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push to remote
|
||||
pub fn push(&self, remote: &str, refspec: &str) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(&["push", remote, refspec])
|
||||
.current_dir(&self.path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Push failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get remote URL
|
||||
pub fn get_remote_url(&self, remote: &str) -> Result<String> {
|
||||
let remote = self.repo.find_remote(remote)?;
|
||||
let url = remote.url()
|
||||
.ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?;
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Check if working directory is clean
|
||||
pub fn is_clean(&self) -> Result<bool> {
|
||||
Ok(!self.has_changes()?)
|
||||
}
|
||||
|
||||
/// Get repository status summary
|
||||
pub fn status_summary(&self) -> Result<StatusSummary> {
|
||||
let statuses = self.repo.statuses(Some(StatusOptions::new().include_untracked(true)))?;
|
||||
|
||||
let mut staged = 0;
|
||||
let mut unstaged = 0;
|
||||
let mut untracked = 0;
|
||||
let mut conflicted = 0;
|
||||
|
||||
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() {
|
||||
staged += 1;
|
||||
}
|
||||
|
||||
if status.is_wt_modified() || status.is_wt_deleted() ||
|
||||
status.is_wt_renamed() || status.is_wt_typechange() {
|
||||
unstaged += 1;
|
||||
}
|
||||
|
||||
if status.is_wt_new() {
|
||||
untracked += 1;
|
||||
}
|
||||
|
||||
if status.is_conflicted() {
|
||||
conflicted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StatusSummary {
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
conflicted,
|
||||
clean: staged == 0 && unstaged == 0 && untracked == 0 && conflicted == 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitInfo {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub message: String,
|
||||
pub author: String,
|
||||
pub email: String,
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
impl CommitInfo {
|
||||
/// Get commit message subject (first line)
|
||||
pub fn subject(&self) -> &str {
|
||||
self.message.lines().next().unwrap_or("")
|
||||
}
|
||||
|
||||
/// Get commit type from conventional commit
|
||||
pub fn commit_type(&self) -> Option<String> {
|
||||
let subject = self.subject();
|
||||
if let Some(colon) = subject.find(':') {
|
||||
let type_part = &subject[..colon];
|
||||
let type_name = type_part.split('(').next()?;
|
||||
Some(type_name.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagInfo {
|
||||
pub name: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Repository status summary
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StatusSummary {
|
||||
pub staged: usize,
|
||||
pub unstaged: usize,
|
||||
pub untracked: usize,
|
||||
pub conflicted: usize,
|
||||
pub clean: bool,
|
||||
}
|
||||
|
||||
impl StatusSummary {
|
||||
/// Format as human-readable string
|
||||
pub fn format(&self) -> String {
|
||||
if self.clean {
|
||||
"working tree clean".to_string()
|
||||
} else {
|
||||
let mut parts = vec![];
|
||||
if self.staged > 0 {
|
||||
parts.push(format!("{} staged", self.staged));
|
||||
}
|
||||
if self.unstaged > 0 {
|
||||
parts.push(format!("{} unstaged", self.unstaged));
|
||||
}
|
||||
if self.untracked > 0 {
|
||||
parts.push(format!("{} untracked", self.untracked));
|
||||
}
|
||||
if self.conflicted > 0 {
|
||||
parts.push(format!("{} conflicted", self.conflicted));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find git repository starting from path and walking up
|
||||
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
|
||||
let start_path = start_path.as_ref();
|
||||
|
||||
if let Ok(repo) = GitRepo::open(start_path) {
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
let mut current = start_path;
|
||||
while let Some(parent) = current.parent() {
|
||||
if let Ok(repo) = GitRepo::open(parent) {
|
||||
return Ok(repo);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
bail!("No git repository found starting from {:?}", start_path)
|
||||
}
|
||||
|
||||
/// Check if path is inside a git repository
|
||||
pub fn is_git_repo<P: AsRef<Path>>(path: P) -> bool {
|
||||
find_repo(path).is_ok()
|
||||
}
|
||||
339
src/git/tag.rs
Normal file
339
src/git/tag.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
use super::GitRepo;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use semver::Version;
|
||||
|
||||
/// Tag builder for creating tags
|
||||
pub struct TagBuilder {
|
||||
name: Option<String>,
|
||||
message: Option<String>,
|
||||
annotate: bool,
|
||||
sign: bool,
|
||||
force: bool,
|
||||
dry_run: bool,
|
||||
version_prefix: String,
|
||||
}
|
||||
|
||||
impl TagBuilder {
|
||||
/// Create new tag builder
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
name: None,
|
||||
message: None,
|
||||
annotate: true,
|
||||
sign: false,
|
||||
force: false,
|
||||
dry_run: false,
|
||||
version_prefix: "v".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set tag name
|
||||
pub fn name(mut self, name: impl Into<String>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set tag message
|
||||
pub fn message(mut self, message: impl Into<String>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Create annotated tag
|
||||
pub fn annotate(mut self, annotate: bool) -> Self {
|
||||
self.annotate = annotate;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sign the tag
|
||||
pub fn sign(mut self, sign: bool) -> Self {
|
||||
self.sign = sign;
|
||||
self
|
||||
}
|
||||
|
||||
/// Force overwrite existing tag
|
||||
pub fn force(mut self, force: bool) -> Self {
|
||||
self.force = force;
|
||||
self
|
||||
}
|
||||
|
||||
/// Dry run (don't actually create tag)
|
||||
pub fn dry_run(mut self, dry_run: bool) -> Self {
|
||||
self.dry_run = dry_run;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set version prefix
|
||||
pub fn version_prefix(mut self, prefix: impl Into<String>) -> Self {
|
||||
self.version_prefix = prefix.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set semantic version
|
||||
pub fn version(mut self, version: &Version) -> Self {
|
||||
self.name = Some(format!("{}{}", self.version_prefix, version));
|
||||
self
|
||||
}
|
||||
|
||||
/// 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)
|
||||
});
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Execute tag creation
|
||||
pub fn execute(&self, repo: &GitRepo) -> Result<()> {
|
||||
let name = self.name.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Tag name is required"))?;
|
||||
|
||||
if self.dry_run {
|
||||
println!("Would create tag: {}", name);
|
||||
if self.annotate {
|
||||
println!("Message: {}", self.build_message()?);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if tag already exists
|
||||
if !self.force {
|
||||
let existing_tags = repo.get_tags()?;
|
||||
if existing_tags.iter().any(|t| t.name == *name) {
|
||||
bail!("Tag '{}' already exists. Use --force to overwrite.", name);
|
||||
}
|
||||
}
|
||||
|
||||
let message = if self.annotate {
|
||||
Some(self.build_message()?.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
repo.create_tag(name, message.as_deref(), self.sign)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TagBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic version bump types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VersionBump {
|
||||
Major,
|
||||
Minor,
|
||||
Patch,
|
||||
Prerelease,
|
||||
}
|
||||
|
||||
impl VersionBump {
|
||||
/// Parse from string
|
||||
pub fn from_str(s: &str) -> Result<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"major" => Ok(Self::Major),
|
||||
"minor" => Ok(Self::Minor),
|
||||
"patch" => Ok(Self::Patch),
|
||||
"prerelease" | "pre" => Ok(Self::Prerelease),
|
||||
_ => bail!("Invalid version bump: {}. Use: major, minor, patch, prerelease", s),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all variants
|
||||
pub fn variants() -> &'static [&'static str] {
|
||||
&["major", "minor", "patch", "prerelease"]
|
||||
}
|
||||
}
|
||||
|
||||
/// 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| {
|
||||
let name = &t.name;
|
||||
let version_str = name.strip_prefix(prefix).unwrap_or(name);
|
||||
Version::parse(version_str).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
versions.sort_by(|a, b| b.cmp(a)); // Descending order
|
||||
|
||||
Ok(versions.into_iter().next())
|
||||
}
|
||||
|
||||
/// Bump version
|
||||
pub fn bump_version(version: &Version, bump: VersionBump, prerelease_id: Option<&str>) -> Version {
|
||||
match bump {
|
||||
VersionBump::Major => Version::new(version.major + 1, 0, 0),
|
||||
VersionBump::Minor => Version::new(version.major, version.minor + 1, 0),
|
||||
VersionBump::Patch => Version::new(version.major, version.minor, version.patch + 1),
|
||||
VersionBump::Prerelease => {
|
||||
let pre = prerelease_id.unwrap_or("alpha");
|
||||
let pre_id = format!("{}.0", pre);
|
||||
Version::parse(&format!("{}-{}", version, pre_id)).unwrap_or_else(|_| version.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggest next version based on commits
|
||||
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") {
|
||||
has_breaking = true;
|
||||
}
|
||||
|
||||
if let Some(commit_type) = commit.commit_type() {
|
||||
match commit_type.as_str() {
|
||||
"feat" => has_feature = true,
|
||||
"fix" => has_fix = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_breaking {
|
||||
VersionBump::Major
|
||||
} else if has_feature {
|
||||
VersionBump::Minor
|
||||
} else if has_fix {
|
||||
VersionBump::Patch
|
||||
} else {
|
||||
VersionBump::Patch
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
"fix" => fixes.push(subject.to_string()),
|
||||
_ => other.push(subject.to_string()),
|
||||
}
|
||||
} else {
|
||||
other.push(subject.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Build message
|
||||
if !breaking.is_empty() {
|
||||
message.push_str("## Breaking Changes\n\n");
|
||||
for item in &breaking {
|
||||
message.push_str(&format!("- {}\n", item));
|
||||
}
|
||||
message.push('\n');
|
||||
}
|
||||
|
||||
if !features.is_empty() {
|
||||
message.push_str("## Features\n\n");
|
||||
for item in &features {
|
||||
message.push_str(&format!("- {}\n", item));
|
||||
}
|
||||
message.push('\n');
|
||||
}
|
||||
|
||||
if !fixes.is_empty() {
|
||||
message.push_str("## Bug Fixes\n\n");
|
||||
for item in &fixes {
|
||||
message.push_str(&format!("- {}\n", item));
|
||||
}
|
||||
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 output = Command::new("git")
|
||||
.args(&["push", remote, ":refs/tags/{}"])
|
||||
.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(())
|
||||
}
|
||||
|
||||
/// List tags with filtering
|
||||
pub fn list_tags(
|
||||
repo: &GitRepo,
|
||||
pattern: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<super::TagInfo>> {
|
||||
let tags = repo.get_tags()?;
|
||||
|
||||
let filtered: Vec<_> = tags
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
if let Some(p) = pattern {
|
||||
t.name.contains(p)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
Ok(filtered.into_iter().take(limit).collect())
|
||||
} else {
|
||||
Ok(filtered)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user