wsb 0.258.6441

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
use crate::fleet::error::{Result, SuperGitError};
use crate::fleet::git::{GitOps, SubmoduleOps, SubmoduleTree};
use crate::fleet::output::Formatter;
use crate::git_backend;
use crate::prelude::*;

pub fn execute<P: AsRef<Path>>(
    repo_root: P,
    init: bool,
    recursive: bool,
    dry_run: bool,
    verbose: bool,
) -> Result<()> {
    let mut failed = 0;
    let mut skipped = 0;

    if dry_run {
        Formatter::print_warning("DRY RUN: No changes will be made");
        return dry_run_report(&repo_root, init, recursive);
    }

    // Initializing first makes nested submodules visible, so the sync pass below
    // operates on the complete tree rather than only what was checked out on entry.
    if init {
        let outcome = SubmoduleOps::init_tree(&repo_root, recursive, |node| {
            Formatter::print_info(&format!("Initializing '{}'...", node.label()));
        })?;
        for name in &outcome.initialized {
            Formatter::print_success(&format!("Initialized '{}'", name));
        }
        for (name, err) in &outcome.failures {
            Formatter::print_error(&format!("Failed to initialize '{}': {}", name, err));
        }
        failed += outcome.failures.len();
    }

    let tree = SubmoduleTree::discover(&repo_root)?;

    Formatter::print_info(&format!(
        "Synchronizing {} submodule(s) based on .gitmodules",
        tree.len()
    ));

    let mut successful = 0;

    // Parent before child: a parent's checkout can change which commit a child is
    // pinned to, so the child must be synced against the parent's new state.
    for node in tree.top_down() {
        if verbose {
            Formatter::print_submodule_header(node.label());
        }

        if !node.initialized {
            Formatter::print_warning(&format!(
                "Submodule '{}' not initialized. Use --init to initialize.",
                node.label()
            ));
            skipped += 1;
            continue;
        }

        if verbose {
            Formatter::print_info("Fetching...");
        }
        if let Err(e) = SubmoduleOps::fetch(node, false) {
            Formatter::print_error(&format!("Failed to fetch '{}': {}", node.label(), e));
            failed += 1;
            continue;
        }

        // Target branch comes from .gitmodules, falling back to whatever the
        // submodule currently has checked out. A detached HEAD has no branch to
        // sync, and guessing one would silently move the checkout.
        let target_branch = match &node.branch {
            Some(branch) => branch.clone(),
            None => {
                let repo = match GitOps::open_repo(&node.abs_path) {
                    Ok(repo) => repo,
                    Err(e) => {
                        Formatter::print_error(&format!(
                            "Failed to open '{}': {}",
                            node.label(),
                            e
                        ));
                        failed += 1;
                        continue;
                    }
                };
                match GitOps::current_branch(&repo) {
                    Ok(branch) => branch,
                    Err(_) => {
                        Formatter::print_warning(&format!(
                            "No branch specified in .gitmodules and '{}' is not on a branch, skipping",
                            node.label()
                        ));
                        skipped += 1;
                        continue;
                    }
                }
            }
        };

        if verbose {
            Formatter::print_info(&format!("Checking out '{}'...", target_branch));
        }
        if let Err(e) = SubmoduleOps::checkout(node, &target_branch, false) {
            Formatter::print_error(&format!(
                "Failed to checkout '{}' in '{}': {}",
                target_branch,
                node.label(),
                e
            ));
            failed += 1;
            continue;
        }

        if verbose {
            Formatter::print_info("Pulling...");
        }
        match SubmoduleOps::pull(node, git_backend::PullOptions::default()) {
            // Diverged is not synced: the branch is behind and the incoming
            // commits could not be taken, so saying "synced" would be a green
            // line over a submodule that still needs a rebase or a merge.
            Ok(git_backend::PullOutcome::Diverged {
                incoming,
                outgoing,
            }) => {
                Formatter::print_error(&format!(
                    "'{}' has diverged on '{}': {incoming} incoming commit(s) and {outgoing} \
                     local commit(s) — rebase or merge them, then sync again",
                    node.label(),
                    target_branch
                ));
                failed += 1;
            }
            Ok(_) => {
                Formatter::print_success(&format!(
                    "Synced '{}' on branch '{}'",
                    node.label(),
                    target_branch
                ));
                successful += 1;
            }
            Err(e) => {
                Formatter::print_error(&format!("Failed to pull '{}': {}", node.label(), e));
                failed += 1;
            }
        }
    }

    Formatter::print_summary(successful, failed, skipped);

    if failed > 0 {
        return Err(SuperGitError::Other(format!(
            "{} submodule(s) failed to sync",
            failed
        )));
    }

    Ok(())
}

/// Report what a real run would do against the tree as it exists now.
///
/// Submodules nested inside an uninitialized parent cannot be listed, because
/// their `.gitmodules` is not on disk yet; we say so rather than implying the
/// listing is complete.
fn dry_run_report<P: AsRef<Path>>(repo_root: P, init: bool, recursive: bool) -> Result<()> {
    let tree = SubmoduleTree::discover(&repo_root)?;

    Formatter::print_info(&format!(
        "Synchronizing {} submodule(s) based on .gitmodules",
        tree.len()
    ));

    let mut hidden_children = false;

    for node in tree.top_down() {
        if node.initialized {
            Formatter::print_info(&format!("Would fetch and sync '{}'", node.label()));
            continue;
        }

        if init {
            Formatter::print_info(&format!("Would initialize '{}'", node.label()));
            if recursive {
                hidden_children = true;
            }
        } else {
            Formatter::print_warning(&format!(
                "Submodule '{}' not initialized. Use --init to initialize.",
                node.label()
            ));
        }
    }

    if hidden_children {
        Formatter::print_info(
            "Submodules nested inside the uninitialized ones above are not listed — they become \
             visible only once their parent is checked out",
        );
    }

    Ok(())
}