git-send 0.1.6

Commit and push changes with a single command
//! Git command execution and repository operations

use anyhow::{Context, Result};
use std::path::Path;
use std::process::{Command, Stdio};

/// Git command runner trait for testability and dependency injection
pub trait GitRunner {
    fn run(&self, args: &[&str]) -> Result<()>;
    fn run_output(&self, args: &[&str]) -> Result<String>;
    fn run_output_full(&self, args: &[&str]) -> Result<(String, String)>;
    #[allow(dead_code)]
    fn run_status(&self, args: &[&str]) -> Result<bool>;
}

/// Real git command runner
pub struct RealGitRunner;

impl GitRunner for RealGitRunner {
    fn run(&self, args: &[&str]) -> Result<()> {
        let status = Command::new("git")
            .args(args)
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .context("Failed to execute git command")?;

        anyhow::ensure!(status.success(), "git command failed");
        Ok(())
    }

    fn run_output(&self, args: &[&str]) -> Result<String> {
        let output = Command::new("git")
            .args(args)
            .output()
            .context("Failed to execute git command")?;

        anyhow::ensure!(output.status.success(), "git command failed");
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
    }

    fn run_output_full(&self, args: &[&str]) -> Result<(String, String)> {
        let output = Command::new("git")
            .args(args)
            .output()
            .context("Failed to execute git command")?;

        anyhow::ensure!(output.status.success(), "git command failed");
        Ok((
            String::from_utf8_lossy(&output.stdout).trim().to_string(),
            String::from_utf8_lossy(&output.stderr).trim().to_string(),
        ))
    }

    fn run_status(&self, args: &[&str]) -> Result<bool> {
        let status = Command::new("git")
            .args(args)
            .status()
            .context("Failed to execute git command")?;
        Ok(status.success())
    }
}

/// Legacy git functions for backward compatibility
pub fn git(args: &[&str]) -> Result<()> {
    let runner = RealGitRunner;
    runner.run(args)
}

pub fn git_output(args: &[&str]) -> Result<String> {
    let runner = RealGitRunner;
    runner.run_output(args)
}

pub fn git_output_full(args: &[&str]) -> Result<(String, String)> {
    let runner = RealGitRunner;
    runner.run_output_full(args)
}

/// Validates that the current directory is a git repository.
pub fn validate_git_repo() -> Result<()> {
    git_output(&["rev-parse", "--git-dir"]).context(
        "Not in a git repository. Please run this command from within a git repository.",
    )?;
    Ok(())
}

/// Checks if a rebase is in progress.
pub fn is_rebase_in_progress() -> Result<bool> {
    let git_dir = git_output(&["rev-parse", "--git-dir"])?;
    let has_rebase_apply = Path::new(&git_dir).join("rebase-apply").exists();
    let has_rebase_merge = Path::new(&git_dir).join("rebase-merge").exists();
    Ok(has_rebase_apply || has_rebase_merge)
}

/// Checks if a merge is in progress.
pub fn is_merge_in_progress() -> Result<bool> {
    let git_dir = git_output(&["rev-parse", "--git-dir"])?;
    Ok(Path::new(&git_dir).join("MERGE_HEAD").exists())
}

/// Aborts ongoing rebase.
pub fn abort_rebase() -> Result<()> {
    git(&["rebase", "--abort"])?;
    Ok(())
}

/// Aborts ongoing merge.
pub fn abort_merge() -> Result<()> {
    git(&["merge", "--abort"])?;
    Ok(())
}

/// Aborts ongoing rebase or merge.
pub fn abort_rebase_or_merge() -> Result<()> {
    if is_rebase_in_progress()? {
        return abort_rebase();
    }

    if is_merge_in_progress()? {
        return abort_merge();
    }

    anyhow::bail!("No rebase or merge in progress to abort");
}

/// Checks for merge conflicts in diff output.
pub fn has_conflicts_in_diff() -> bool {
    let output = git_output(&["diff", "--check"]).unwrap_or_default();
    !output.is_empty()
}

/// Checks for merge conflicts in unmerged files.
pub fn has_conflicts_in_unmerged() -> Result<bool> {
    let status = Command::new("git")
        .args(["ls-files", "-u"])
        .output()
        .context("Failed to check for merge conflicts")?;
    Ok(!status.stdout.is_empty())
}

/// Checks if there are merge conflicts.
pub fn has_merge_conflicts() -> Result<bool> {
    if has_conflicts_in_diff() {
        return Ok(true);
    }

    has_conflicts_in_unmerged()
}

/// Gets the current branch name.
pub fn get_current_branch() -> Result<String> {
    git_output(&["rev-parse", "--abbrev-ref", "HEAD"]).context("Failed to determine current branch")
}

/// Gets the number of commits ahead/behind upstream.
pub fn get_commit_counts(branch: &str, upstream: &str) -> (usize, usize) {
    let ahead = git_output(&["rev-list", "--count", &format!("{upstream}..{branch}")])
        .unwrap_or_else(|_| "0".to_string())
        .parse()
        .unwrap_or(0);
    let behind = git_output(&["rev-list", "--count", &format!("{branch}..{upstream}")])
        .unwrap_or_else(|_| "0".to_string())
        .parse()
        .unwrap_or(0);
    (ahead, behind)
}

/// Gets recent commit messages to detect WIP commits.
pub fn get_recent_commits(count: usize) -> Result<Vec<String>> {
    let output = git_output(&["log", "--format=%s", "-n", &count.to_string()])?;
    Ok(output
        .lines()
        .map(std::string::ToString::to_string)
        .collect())
}