cyrup_release 0.5.4

Production-quality release management for Rust workspaces
Documentation
//! Git operations using CLI commands via tokio::process

use crate::error::{Result, GitError};
use semver::Version;
use std::path::{Path, PathBuf};
use tokio::process::Command;

/// Trait defining required Git operations for release management
#[async_trait::async_trait]
pub trait GitOperations {
    /// Creates a release commit with all staged changes
    async fn create_release_commit(&self, version: &Version, message: Option<String>) -> Result<CommitInfo>;
    
    /// Creates an annotated git tag for the specified version
    async fn create_version_tag(&self, version: &Version, message: Option<String>) -> Result<TagInfo>;
    
    /// Pushes commits and optionally tags to a remote repository
    async fn push_to_remote(&self, remote_name: Option<&str>, push_tags: bool) -> Result<PushInfo>;
    
    /// Checks if the working directory has uncommitted changes
    async fn is_working_directory_clean(&self) -> Result<bool>;
    
    /// Gets information about the current branch
    async fn get_current_branch(&self) -> Result<BranchInfo>;
    
    /// Checks if a tag with the given name exists
    async fn tag_exists(&self, tag_name: &str) -> Result<bool>;
    
    /// Deletes a tag locally and optionally from the remote
    async fn delete_tag(&self, tag_name: &str, delete_remote: bool) -> Result<()>;
    
    /// Resets the repository to a specific commit
    async fn reset_to_commit(&self, commit_id: &str, reset_type: ResetType) -> Result<()>;
    
    /// Retrieves the N most recent commits
    async fn get_recent_commits(&self, count: usize) -> Result<Vec<CommitInfo>>;
    
    /// Gets all configured git remotes
    async fn get_remotes(&self) -> Result<Vec<RemoteInfo>>;
    
    /// Validates that the repository is ready for a release
    async fn validate_release_readiness(&self) -> Result<ValidationResult>;
}

/// Information about a git commit
#[derive(Debug, Clone)]
pub struct CommitInfo {
    /// Full commit hash
    pub hash: String,
    /// Abbreviated commit hash
    pub short_hash: String,
    /// Commit message
    pub message: String,
    /// Author name
    pub author_name: String,
    /// Author email address
    pub author_email: String,
    /// Commit timestamp in UTC
    pub timestamp: chrono::DateTime<chrono::Utc>,
    /// Parent commit hashes
    pub parents: Vec<String>,
}

/// Information about a git tag
#[derive(Debug, Clone)]
pub struct TagInfo {
    /// Tag name
    pub name: String,
    /// Optional tag message for annotated tags
    pub message: Option<String>,
    /// Commit hash the tag points to
    pub target_commit: String,
    /// Tag creation timestamp in UTC
    pub timestamp: chrono::DateTime<chrono::Utc>,
    /// Whether this is an annotated tag
    pub is_annotated: bool,
}

/// Information about a git push operation
#[derive(Debug, Clone)]
pub struct PushInfo {
    /// Name of the remote that was pushed to
    pub remote_name: String,
    /// Number of commits pushed
    pub commits_pushed: usize,
    /// Number of tags pushed
    pub tags_pushed: usize,
    /// Any warnings generated during the push
    pub warnings: Vec<String>,
}

/// Information about a git branch
#[derive(Debug, Clone)]
pub struct BranchInfo {
    /// Branch name
    pub name: String,
    /// Whether this is the current HEAD branch
    pub is_head: bool,
    /// Upstream tracking branch if configured
    pub upstream: Option<String>,
    /// Commit hash the branch points to
    pub commit_hash: String,
}

/// Information about a git remote
#[derive(Debug, Clone)]
pub struct RemoteInfo {
    /// Remote name (e.g., "origin")
    pub name: String,
    /// URL for fetching from the remote
    pub fetch_url: String,
    /// URL for pushing to the remote
    pub push_url: String,
}

/// Git reset type specifying what to reset
#[derive(Debug, Clone, Copy)]
pub enum ResetType {
    /// Keep staged changes and working directory changes
    Soft,
    /// Keep working directory changes but reset the index
    Mixed,
    /// Reset both index and working directory
    Hard,
}

/// Result of validating release readiness
#[derive(Debug, Clone)]
pub struct ValidationResult {
    /// Whether the repository is ready for release
    pub is_valid: bool,
    /// List of issues preventing release
    pub issues: Vec<String>,
}

/// Git repository handle using CLI commands
#[derive(Debug, Clone)]
pub struct GitRepository {
    repo_path: PathBuf,
}

impl GitRepository {
    /// Discovers a git repository starting from the given path
    pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref();
        Ok(Self {
            repo_path: path.to_path_buf(),
        })
    }

    /// Opens a git repository at the given path
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        Self::discover(path)
    }

    /// Returns the path to the repository root
    pub fn repo_path(&self) -> &Path {
        &self.repo_path
    }

    async fn run_git(&self, args: &[&str]) -> Result<std::process::Output> {
        Command::new("git")
            .args(args)
            .current_dir(&self.repo_path)
            .output()
            .await
            .map_err(|e| GitError::OperationFailed {
                operation: format!("git {}", args.join(" ")),
                reason: format!("Failed to execute: {}", e),
            }.into())
    }

    async fn run_git_checked(&self, args: &[&str]) -> Result<String> {
        let output = self.run_git(args).await?;
        
        if !output.status.success() {
            return Err(GitError::OperationFailed {
                operation: format!("git {}", args.join(" ")),
                reason: String::from_utf8_lossy(&output.stderr).to_string(),
            }.into());
        }
        
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
    }
}

#[async_trait::async_trait]
impl GitOperations for GitRepository {
    async fn create_release_commit(&self, version: &Version, message: Option<String>) -> Result<CommitInfo> {
        let commit_message = message.unwrap_or_else(|| format!("release: v{}", version));
        
        // Stage all changes
        self.run_git_checked(&["add", "-A"]).await?;
        
        // Create commit
        self.run_git_checked(&["commit", "-m", &commit_message]).await?;
        
        // Get commit info
        let output = self.run_git_checked(&["log", "-1", "--format=%H%n%h%n%s%n%an%n%ae"]).await?;
        let lines: Vec<&str> = output.lines().collect();
        
        if lines.len() < 5 {
            return Err(GitError::OperationFailed {
                operation: "get commit info".to_string(),
                reason: "Unexpected git log output format".to_string(),
            }.into());
        }
        
        Ok(CommitInfo {
            hash: lines[0].to_string(),
            short_hash: lines[1].to_string(),
            message: lines[2].to_string(),
            author_name: lines[3].to_string(),
            author_email: lines[4].to_string(),
            timestamp: chrono::Utc::now(),
            parents: Vec::new(),
        })
    }

    async fn create_version_tag(&self, version: &Version, message: Option<String>) -> Result<TagInfo> {
        let tag_name = format!("v{}", version);
        let tag_message = message.unwrap_or_else(|| format!("Release v{}", version));
        
        // Create annotated tag
        self.run_git_checked(&["tag", "-a", &tag_name, "-m", &tag_message]).await?;
        
        // Get tag info
        let commit = self.run_git_checked(&["rev-parse", &tag_name]).await?;
        
        Ok(TagInfo {
            name: tag_name,
            message: Some(tag_message),
            target_commit: commit,
            timestamp: chrono::Utc::now(),
            is_annotated: true,
        })
    }

    async fn push_to_remote(&self, remote_name: Option<&str>, push_tags: bool) -> Result<PushInfo> {
        let remote = remote_name.unwrap_or("origin");
        
        // Push commits
        self.run_git_checked(&["push", remote, "HEAD"]).await?;
        
        let mut tags_pushed = 0;
        if push_tags {
            // Push tags
            self.run_git_checked(&["push", remote, "--tags"]).await?;
            tags_pushed = 1; // Simplified
        }
        
        Ok(PushInfo {
            remote_name: remote.to_string(),
            commits_pushed: 1, // Simplified
            tags_pushed,
            warnings: Vec::new(),
        })
    }

    async fn is_working_directory_clean(&self) -> Result<bool> {
        let output = self.run_git_checked(&["status", "--porcelain"]).await?;
        Ok(output.is_empty())
    }

    async fn get_current_branch(&self) -> Result<BranchInfo> {
        let name = self.run_git_checked(&["branch", "--show-current"]).await?;
        let commit_hash = self.run_git_checked(&["rev-parse", "HEAD"]).await?;
        
        let upstream = self.run_git(&["rev-parse", "--abbrev-ref", "@{upstream}"])
            .await
            .ok()
            .and_then(|output| {
                if output.status.success() {
                    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
                } else {
                    None
                }
            });
        
        Ok(BranchInfo {
            name,
            is_head: true,
            upstream,
            commit_hash,
        })
    }

    async fn tag_exists(&self, tag_name: &str) -> Result<bool> {
        let output = self.run_git(&["tag", "-l", tag_name]).await?;
        Ok(output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty())
    }

    async fn delete_tag(&self, tag_name: &str, delete_remote: bool) -> Result<()> {
        // Delete local tag
        self.run_git_checked(&["tag", "-d", tag_name]).await?;
        
        if delete_remote {
            // Delete remote tag
            self.run_git_checked(&["push", "origin", &format!(":{}", tag_name)]).await?;
        }
        
        Ok(())
    }

    async fn reset_to_commit(&self, commit_id: &str, reset_type: ResetType) -> Result<()> {
        let reset_arg = match reset_type {
            ResetType::Soft => "--soft",
            ResetType::Mixed => "--mixed",
            ResetType::Hard => "--hard",
        };
        
        self.run_git_checked(&["reset", reset_arg, commit_id]).await?;
        Ok(())
    }

    async fn get_recent_commits(&self, count: usize) -> Result<Vec<CommitInfo>> {
        let output = self.run_git_checked(&[
            "log",
            &format!("-{}", count),
            "--format=%H%x00%h%x00%s%x00%an%x00%ae%x00"
        ]).await?;
        
        let mut commits = Vec::new();
        for entry in output.split('\n').filter(|s| !s.is_empty()) {
            let parts: Vec<&str> = entry.split('\0').collect();
            if parts.len() >= 5 {
                commits.push(CommitInfo {
                    hash: parts[0].to_string(),
                    short_hash: parts[1].to_string(),
                    message: parts[2].to_string(),
                    author_name: parts[3].to_string(),
                    author_email: parts[4].to_string(),
                    timestamp: chrono::Utc::now(),
                    parents: Vec::new(),
                });
            }
        }
        
        Ok(commits)
    }

    async fn get_remotes(&self) -> Result<Vec<RemoteInfo>> {
        let output = self.run_git_checked(&["remote", "-v"]).await?;
        
        let mut remotes = std::collections::HashMap::new();
        for line in output.lines() {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() >= 3 {
                let name = parts[0];
                let url = parts[1];
                let remote_type = parts[2].trim_matches(|c| c == '(' || c == ')');
                
                let entry = remotes.entry(name.to_string()).or_insert_with(|| RemoteInfo {
                    name: name.to_string(),
                    fetch_url: String::new(),
                    push_url: String::new(),
                });
                
                if remote_type == "fetch" {
                    entry.fetch_url = url.to_string();
                } else if remote_type == "push" {
                    entry.push_url = url.to_string();
                }
            }
        }
        
        Ok(remotes.into_values().collect())
    }

    async fn validate_release_readiness(&self) -> Result<ValidationResult> {
        let mut issues = Vec::new();
        
        // Check if working directory is clean
        if !self.is_working_directory_clean().await? {
            issues.push("Working directory has uncommitted changes".to_string());
        }
        
        // Check if we have a remote
        let remotes = self.get_remotes().await?;
        if remotes.is_empty() {
            issues.push("No git remotes configured".to_string());
        }
        
        // Check if on a branch
        let branch = self.get_current_branch().await;
        if let Err(_) = branch {
            issues.push("Not on a branch (detached HEAD)".to_string());
        }
        
        Ok(ValidationResult {
            is_valid: issues.is_empty(),
            issues,
        })
    }
}

impl BranchInfo {
    /// Returns the commit hash as a string slice
    pub fn commit_hash(&self) -> &str {
        &self.commit_hash
    }
}