wsb 0.153.17

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
use crate::fleet::config::GitModules;
use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::git::GitOps;
use crate::fleet::output::Formatter;
use crate::git_backend;
use std::path::{Path, PathBuf};

/// A single action taken during the merge-back process, for audit trail.
#[derive(Debug)]
struct Action {
    repo_name: String,
    description: String,
    succeeded: bool,
}

/// Tracks everything the merge-back does for chronological replay on failure.
struct AuditLog {
    actions: Vec<Action>,
}

impl AuditLog {
    fn new() -> Self {
        Self {
            actions: Vec::new(),
        }
    }

    fn record(&mut self, repo_name: &str, description: &str, succeeded: bool) {
        self.actions.push(Action {
            repo_name: repo_name.to_string(),
            description: description.to_string(),
            succeeded,
        });
    }

    fn print_and_fail(&self, final_error: &str) -> SuperGitError {
        eprintln!();
        Formatter::print_error("Merge-back failed. Chronological action log:");
        eprintln!();
        for (i, action) in self.actions.iter().enumerate() {
            let symbol = if action.succeeded { "✓" } else { "✗" };
            let color_symbol = if action.succeeded {
                format!("\x1b[32m{}\x1b[0m", symbol) // green
            } else {
                format!("\x1b[31m{}\x1b[0m", symbol) // red
            };
            eprintln!(
                "  {:>3}. {} [{}] {}",
                i + 1,
                color_symbol,
                action.repo_name,
                action.description,
            );
        }
        eprintln!();
        SuperGitError::Other(final_error.to_string())
    }
}

/// Info collected about each repo during pre-flight.
struct RepoInfo {
    name: String,
    path: PathBuf,
    current_branch: String,
    main_branch: String,
}

pub fn execute<P: AsRef<Path>>(
    repo_root: P,
    message: &str,
    dry_run: bool,
    verbose: bool,
) -> Result<()> {
    let repo_root = repo_root.as_ref();
    let gitmodules = GitModules::parse(repo_root)?;

    Formatter::print_info(&format!(
        "Merge-back: superproject + {} submodule(s)",
        gitmodules.submodules.len()
    ));

    // ── Phase 1: Pre-flight checks ──────────────────────────────────────
    Formatter::print_info("Running pre-flight checks...");

    let mut repos: Vec<RepoInfo> = Vec::new();

    // Check superproject
    {
        let super_repo = GitOps::open_repo(repo_root)?;

        if GitOps::is_detached_head(&super_repo)? {
            return Err(SuperGitError::Other(
                "Superproject is in detached HEAD state".to_string(),
            ));
        }

        let current_branch = GitOps::current_branch(&super_repo)?;
        let main_branch = GitOps::get_default_branch(&super_repo)?;

        if current_branch == main_branch {
            return Err(SuperGitError::Other(format!(
                "Superproject is already on '{}'. Merge-back requires being on a feature branch.",
                main_branch
            )));
        }

        // Verify main branch exists (get_default_branch already does this, but be explicit)
        if !GitOps::branch_exists(&super_repo, &main_branch)? {
            return Err(SuperGitError::Other(format!(
                "Superproject has no '{}' branch",
                main_branch
            )));
        }

        if verbose {
            Formatter::print_info(&format!(
                "Superproject: on '{}', will merge into '{}'",
                current_branch, main_branch
            ));
        }

        repos.push(RepoInfo {
            name: "[SUPERPROJECT]".to_string(),
            path: repo_root.to_path_buf(),
            current_branch,
            main_branch,
        });
    }

    // Check each submodule
    for submodule in &gitmodules.submodules {
        let submodule_path = repo_root.join(&submodule.path);

        if !submodule_path.exists() {
            return Err(SuperGitError::Other(format!(
                "Submodule '{}' is not initialized. All submodules must be initialized for merge-back.",
                submodule.name
            )));
        }

        let sub_repo = GitOps::open_repo(&submodule_path)?;

        if GitOps::is_detached_head(&sub_repo)? {
            return Err(SuperGitError::Other(format!(
                "Submodule '{}' is in detached HEAD state",
                submodule.name
            )));
        }

        let current_branch = GitOps::current_branch(&sub_repo)?;
        let main_branch = GitOps::get_default_branch(&sub_repo)?;

        if current_branch == main_branch {
            return Err(SuperGitError::Other(format!(
                "Submodule '{}' is already on '{}'. Merge-back requires all repos to be on a feature branch.",
                submodule.name, main_branch
            )));
        }

        if !GitOps::branch_exists(&sub_repo, &main_branch)? {
            return Err(SuperGitError::Other(format!(
                "Submodule '{}' has no '{}' branch",
                submodule.name, main_branch
            )));
        }

        if verbose {
            Formatter::print_info(&format!(
                "'{}': on '{}', will merge into '{}'",
                submodule.name, current_branch, main_branch
            ));
        }

        repos.push(RepoInfo {
            name: submodule.name.clone(),
            path: submodule_path,
            current_branch,
            main_branch,
        });
    }

    Formatter::print_success(&format!("Pre-flight passed for {} repo(s)", repos.len()));

    if dry_run {
        Formatter::print_warning("DRY RUN: No changes will be made");
        println!();
        Formatter::print_info("Merge-back plan:");
        for repo in &repos {
            println!(
                "  {} [{}] '{}' → checkout '{}' → merge --ff-only '{}' → push",
                "→".cyan(),
                repo.name,
                repo.current_branch,
                repo.main_branch,
                repo.current_branch,
            );
        }
        return Ok(());
    }

    // ── Phase 2: Stage, commit, push all repos on current branch ────────
    let mut log = AuditLog::new();

    // Stage and commit all repos
    for repo_info in &repos {
        let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
            log.record(&repo_info.name, &format!("Open repo: {}", e), false);
            log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
        })?;

        let is_clean = GitOps::is_clean(&repo).map_err(|e| {
            log.record(&repo_info.name, &format!("Check clean: {}", e), false);
            log.print_and_fail(&format!("Failed to check status of '{}'", repo_info.name))
        })?;

        if !is_clean {
            git_backend::stage_all(&repo).map_err(|e| {
                log.record(&repo_info.name, &format!("Stage all changes: {}", e), false);
                log.print_and_fail(&format!("Failed to stage in '{}'", repo_info.name))
            })?;
            log.record(&repo_info.name, "Staged all changes", true);

            match git_backend::commit_all(&repo, message).map_err(|e| {
                log.record(&repo_info.name, &format!("Commit: {}", e), false);
                log.print_and_fail(&format!("Failed to commit in '{}'", repo_info.name))
            })? {
                Some(_) => log.record(
                    &repo_info.name,
                    &format!("Committed on '{}': {}", repo_info.current_branch, message),
                    true,
                ),
                None => log.record(
                    &repo_info.name,
                    "Nothing to commit (clean after staging)",
                    true,
                ),
            }
        } else {
            log.record(&repo_info.name, "Already clean, no commit needed", true);
        }
    }

    // Push all repos
    for repo_info in &repos {
        let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
            log.record(&repo_info.name, &format!("Open repo for feature push: {}", e), false);
            log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
        })?;
        git_backend::push_current_branch(&repo, Some("origin"), false, false).map_err(|e| {
            log.record(
                &repo_info.name,
                &format!("Push '{}' to origin: {}", repo_info.current_branch, e),
                false,
            );
            log.print_and_fail(&format!("Failed to push '{}'", repo_info.name))
        })?;
        log.record(
            &repo_info.name,
            &format!("Pushed '{}' to origin", repo_info.current_branch),
            true,
        );
    }

    // ── Phase 3: Checkout main in all repos ─────────────────────────────
    for repo_info in &repos {
        let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
            log.record(&repo_info.name, &format!("Open repo for checkout: {}", e), false);
            log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
        })?;
        git_backend::checkout_branch(&repo, &repo_info.main_branch).map_err(|e| {
            log.record(
                &repo_info.name,
                &format!("Checkout '{}': {}", repo_info.main_branch, e),
                false,
            );
            log.print_and_fail(&format!(
                "Failed to checkout '{}' in '{}'",
                repo_info.main_branch, repo_info.name
            ))
        })?;
        log.record(
            &repo_info.name,
            &format!("Checked out '{}'", repo_info.main_branch),
            true,
        );
    }

    // ── Phase 4: Fast-forward merge from feature branch ─────────────────
    for repo_info in &repos {
        let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
            log.record(&repo_info.name, &format!("Open repo for merge: {}", e), false);
            log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
        })?;
        if let Err(e) = git_backend::merge_ff(&repo, &repo_info.current_branch) {
            log.record(
                &repo_info.name,
                &format!(
                    "Merge --ff-only '{}' into '{}': {}",
                    repo_info.current_branch, repo_info.main_branch, e
                ),
                false,
            );
            return Err(log.print_and_fail(&format!(
                "Cannot fast-forward merge '{}' into '{}' in '{}'. \
                 The branch may have diverged from '{}'. \
                 Rebase onto '{}' first.",
                repo_info.current_branch,
                repo_info.main_branch,
                repo_info.name,
                repo_info.main_branch,
                repo_info.main_branch,
            )));
        }
        log.record(
            &repo_info.name,
            &format!(
                "Merged '{}' into '{}' (fast-forward)",
                repo_info.current_branch, repo_info.main_branch
            ),
            true,
        );
    }

    // ── Phase 5: Push main in all repos ─────────────────────────────────
    for repo_info in &repos {
        let repo = GitOps::open_repo(&repo_info.path).map_err(|e| {
            log.record(&repo_info.name, &format!("Open repo for push: {}", e), false);
            log.print_and_fail(&format!("Failed to open '{}'", repo_info.name))
        })?;
        git_backend::push_current_branch(&repo, Some("origin"), false, false).map_err(|e| {
            log.record(
                &repo_info.name,
                &format!("Push '{}' to origin: {}", repo_info.main_branch, e),
                false,
            );
            log.print_and_fail(&format!(
                "Failed to push '{}' in '{}'",
                repo_info.main_branch, repo_info.name
            ))
        })?;
        log.record(
            &repo_info.name,
            &format!("Pushed '{}' to origin", repo_info.main_branch),
            true,
        );
    }

    // ── Done ────────────────────────────────────────────────────────────
    println!();
    Formatter::print_success("Merge-back complete!");
    println!();
    for repo_info in &repos {
        Formatter::print_success(&format!(
            "[{}] '{}' merged into '{}' and pushed",
            repo_info.name, repo_info.current_branch, repo_info.main_branch
        ));
    }

    Ok(())
}

use colored::Colorize;