xbp 10.28.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use colored::Colorize;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use tokio::process::Command;

use crate::utils::command_exists;

pub struct AutoCommitRequest<'a> {
    pub project_root: &'a Path,
    pub paths: Vec<PathBuf>,
    pub message: String,
    pub action_label: &'a str,
}

pub enum AutoCommitResult {
    Committed(AutoCommitOutcome),
    Skipped(String),
}

pub struct AutoCommitOutcome {
    pub repo_name: String,
    pub repo_root: PathBuf,
    pub branch: Option<String>,
    pub commit_sha: String,
    pub short_sha: String,
    pub message: String,
    pub committed_files: Vec<String>,
}

pub struct PushOutcome {
    pub branch: String,
}

pub async fn commit_paths(request: AutoCommitRequest<'_>) -> Result<AutoCommitResult, String> {
    if !command_exists("git") {
        return Ok(AutoCommitResult::Skipped(
            "Git is not installed on this machine.".to_string(),
        ));
    }

    let repo_root = match git_output(request.project_root, &["rev-parse", "--show-toplevel"]).await
    {
        Ok(root) => PathBuf::from(root),
        Err(_) => {
            return Ok(AutoCommitResult::Skipped(
                "Current project is not inside a git repository.".to_string(),
            ));
        }
    };

    let normalized_paths = normalize_commit_paths(request.project_root, &request.paths);
    if normalized_paths.is_empty() {
        return Ok(AutoCommitResult::Skipped(
            "No generated or updated files were provided for auto-commit.".to_string(),
        ));
    }

    let mut add_args = vec!["add".to_string(), "--all".to_string(), "--".to_string()];
    add_args.extend(normalized_paths.iter().cloned());
    git_output_owned(request.project_root, add_args).await?;

    let mut diff_args = vec![
        "diff".to_string(),
        "--cached".to_string(),
        "--name-only".to_string(),
        "--".to_string(),
    ];
    diff_args.extend(normalized_paths.iter().cloned());
    let committed_files = git_output_owned(request.project_root, diff_args)
        .await?
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(|line| line.replace('\\', "/"))
        .collect::<Vec<_>>();

    if committed_files.is_empty() {
        return Ok(AutoCommitResult::Skipped(
            "Target files did not produce any staged git diff.".to_string(),
        ));
    }

    git_output(
        request.project_root,
        &["commit", "-m", request.message.as_str()],
    )
    .await?;

    let commit_sha = git_output(request.project_root, &["rev-parse", "HEAD"]).await?;
    let short_sha = git_output(request.project_root, &["rev-parse", "--short", "HEAD"]).await?;
    let branch = git_output(request.project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
        .await
        .ok()
        .filter(|value| !value.is_empty() && value != "HEAD");

    let outcome = AutoCommitOutcome {
        repo_name: repo_name(&repo_root),
        repo_root,
        branch,
        commit_sha,
        short_sha,
        message: request.message,
        committed_files,
    };

    print_commit_summary(request.action_label, &outcome);

    Ok(AutoCommitResult::Committed(outcome))
}

pub async fn push_current_branch(project_root: &Path) -> Result<Option<PushOutcome>, String> {
    if !command_exists("git") {
        return Ok(None);
    }

    let branch = git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"])
        .await
        .ok()
        .filter(|value| !value.is_empty() && value != "HEAD");

    let Some(branch) = branch else {
        return Ok(None);
    };

    match git_output(project_root, &["push"]).await {
        Ok(_) => {}
        Err(push_error) => {
            git_output(project_root, &["push", "-u", "origin", branch.as_str()])
                .await
                .map_err(|fallback_error| {
                    format!(
                        "Git push failed (`git push`: {}; `git push -u origin {}`: {})",
                        push_error, branch, fallback_error
                    )
                })?;
        }
    }
    Ok(Some(PushOutcome { branch }))
}

pub fn print_skip(action_label: &str, reason: &str) {
    println!(
        "{} {} {}",
        "Auto-commit".bright_yellow().bold(),
        format!("skipped for {}", action_label).bright_white(),
        format!("({})", reason).dimmed()
    );
}

pub fn print_push_summary(outcome: &PushOutcome) {
    println!(
        "{} {}",
        "Pushed".bright_green().bold(),
        format!("origin/{}", outcome.branch).bright_white()
    );
}

fn print_commit_summary(action_label: &str, outcome: &AutoCommitOutcome) {
    let branch = outcome
        .branch
        .as_deref()
        .map(|value| format!(" on {}", value.bright_blue()))
        .unwrap_or_default();
    let files = if outcome.committed_files.is_empty() {
        "(none)".dimmed().to_string()
    } else {
        outcome
            .committed_files
            .iter()
            .map(|value| value.bright_white().to_string())
            .collect::<Vec<_>>()
            .join(", ")
    };

    println!(
        "{} {}{}",
        "Auto-commit".bright_green().bold(),
        format!("created for {}", action_label).bright_white(),
        branch
    );
    println!(
        "  {} {} {}",
        "Repo".bright_cyan().bold(),
        outcome.repo_name.bright_white().bold(),
        format!("({})", outcome.repo_root.display()).dimmed()
    );
    println!(
        "  {} {} {}",
        "Commit".bright_cyan().bold(),
        outcome.short_sha.bright_green().bold(),
        format!("({})", outcome.commit_sha).dimmed()
    );
    println!(
        "  {} {}",
        "Message".bright_cyan().bold(),
        outcome.message.bright_magenta()
    );
    println!("  {} {}", "Files".bright_cyan().bold(), files);
}

async fn git_output(project_root: &Path, args: &[&str]) -> Result<String, String> {
    let owned_args = args
        .iter()
        .map(|value| value.to_string())
        .collect::<Vec<_>>();
    git_output_owned(project_root, owned_args).await
}

async fn git_output_owned(project_root: &Path, args: Vec<String>) -> Result<String, String> {
    let output = Command::new("git")
        .current_dir(project_root)
        .args(&args)
        .output()
        .await
        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        if stderr.is_empty() {
            return Err(format!(
                "`git {}` failed with status {}",
                args.join(" "),
                output.status
            ));
        }
        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn normalize_commit_paths(project_root: &Path, paths: &[PathBuf]) -> Vec<String> {
    let mut deduped = BTreeSet::new();

    for path in paths {
        if path.as_os_str().is_empty() {
            continue;
        }

        let normalized = if let Ok(relative) = path.strip_prefix(project_root) {
            relative.to_path_buf()
        } else if let Some(relative) = strip_project_root_prefix(project_root, path) {
            relative
        } else {
            path.to_path_buf()
        };

        let rendered = normalized.to_string_lossy().replace('\\', "/");
        let trimmed = rendered.trim();
        if !trimmed.is_empty() && trimmed != "." {
            deduped.insert(trimmed.to_string());
        }
    }

    deduped.into_iter().collect()
}

fn strip_project_root_prefix(project_root: &Path, path: &Path) -> Option<PathBuf> {
    let root = project_root
        .to_string_lossy()
        .replace('\\', "/")
        .trim_end_matches('/')
        .to_string();
    let candidate = path.to_string_lossy().replace('\\', "/");

    if candidate.len() <= root.len() {
        return None;
    }

    let (prefix, suffix) = candidate.split_at(root.len());
    if prefix.eq_ignore_ascii_case(&root) && suffix.starts_with('/') {
        return Some(PathBuf::from(suffix.trim_start_matches('/')));
    }

    None
}

fn repo_name(path: &Path) -> String {
    path.file_name()
        .and_then(|value| value.to_str())
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("repository")
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::normalize_commit_paths;
    use std::path::{Path, PathBuf};

    #[test]
    fn normalizes_commit_paths_relative_to_project_root() {
        let project_root = Path::new("C:/repo");
        let paths = vec![
            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
            PathBuf::from("C:/repo/.xbp/xbp.yaml"),
            PathBuf::from("CHANGELOG.md"),
        ];

        let normalized = normalize_commit_paths(project_root, &paths);

        assert_eq!(
            normalized,
            vec![".xbp/xbp.yaml".to_string(), "CHANGELOG.md".to_string()]
        );
    }
}