feat:(first commit)created repository and complete 0.1.0

This commit is contained in:
2026-01-30 14:18:32 +08:00
commit 5d4156e5e0
36 changed files with 8686 additions and 0 deletions

480
src/git/changelog.rs Normal file
View 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
View 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
View 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
View 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)
}
}