wsb 0.253.6349

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, SubmoduleNode, SubmoduleOps, SubmoduleTree};
use crate::fleet::output::Formatter;
use crate::git_backend;
use crate::prelude::*;
use crate::workspace_state::WorkspaceState;
use std::collections::HashMap;

/// The order a fleet's submodules are committed in.
///
/// Two rules, one order. A child before its parent, which records the
/// child's new commit. And a repository after every repository whose version
/// its templates read (`state::wstemplate::render_dependencies`): a commit
/// moves its repository's version, and a commit renders, so a repository
/// committed before one it reads was rendered from that one's version a moment
/// before it moved. Ordered by nesting alone, every checkpoint committed
/// floom-uikit's and machine-notation-lsp's locks against capdag-js's and
/// tagged-urn's previous versions, and the next checkpoint committed the
/// catch-up. Ties keep the deepest-first order; a cycle is refused, naming it.
fn commit_order<'a>(tree: &'a SubmoduleTree, root: &Path) -> Result<Vec<&'a SubmoduleNode>> {
    let nodes = tree.bottom_up();
    let index: HashMap<&Path, usize> = nodes.iter().enumerate().map(|(i, node)| (node.abs_path.as_path(), i)).collect();
    let paths: Vec<PathBuf> = nodes.iter().map(|node| node.abs_path.clone()).collect();
    let mut repositories = paths.clone();
    repositories.push(root.to_path_buf());
    let reads = crate::state::wstemplate::render_dependencies(root, &repositories)
        .map_err(|e| SuperGitError::Other(format!("reading which repositories each one's templates read: {e:#}")))?;

    // `before[i]`: the nodes that must be committed before node `i`.
    let mut before: Vec<std::collections::BTreeSet<usize>> = vec![Default::default(); nodes.len()];
    for (i, node) in nodes.iter().enumerate() {
        if let Some(&parent) = index.get(node.parent_path.as_path()) {
            before[parent].insert(i);
        }
        for read in reads.get(&node.abs_path).into_iter().flatten() {
            if let Some(&dependency) = index.get(read.as_path()) {
                before[i].insert(dependency);
            }
        }
    }

    let mut order = Vec::with_capacity(nodes.len());
    let mut done = vec![false; nodes.len()];
    while order.len() < nodes.len() {
        let next = (0..nodes.len()).find(|&i| !done[i] && before[i].iter().all(|&b| done[b]));
        match next {
            Some(i) => {
                done[i] = true;
                order.push(nodes[i]);
            }
            None => {
                let stuck: Vec<&str> = (0..nodes.len()).filter(|&i| !done[i]).map(|i| nodes[i].label()).collect();
                return Err(SuperGitError::Other(format!(
                    "these repositories read each other's versions or nest in a way that leaves no order to commit them in: {}",
                    stuck.join(", ")
                )));
            }
        }
    }
    Ok(order)
}

#[allow(clippy::too_many_arguments)]
pub fn execute<P: AsRef<Path>>(
    repo_root: P,
    message: &str,
    push: bool,
    stage_all: bool,
    exclude: &[String],
    include: &[String],
    force_all: bool,
    dry_run: bool,
    verbose: bool,
) -> Result<()> {
    let tree = SubmoduleTree::discover(&repo_root)?;

    Formatter::print_info(&format!(
        "Committing changes in superproject + {} submodule(s)",
        tree.len()
    ));

    if dry_run {
        Formatter::print_warning("DRY RUN: No changes will be made");
    }

    let mut successful = 0;
    let mut failed = 0;
    let mut skipped = 0;

    // Gitlinks created by committing a child, grouped by the parent that must
    // record them. Populated as we go and drained when we reach that parent.
    let mut pending_gitlinks: HashMap<PathBuf, Vec<String>> = HashMap::new();

    // Children before their parent — a parent can only record a child's new
    // commit after that commit exists — and every repository after those whose
    // versions its templates read (`commit_order`).
    for node in commit_order(&tree, repo_root.as_ref())? {
        if verbose {
            Formatter::print_submodule_header(node.label());
        }

        if !selected(node, include, exclude, verbose)? {
            skipped += 1;
            continue;
        }

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

        // Resolved before anything is mutated: if we could not name this path to
        // git afterwards, we would commit a child whose gitlink can never be
        // recorded in its parent.
        let gitlink = node.rel_path.to_str().ok_or_else(|| {
            SuperGitError::Other(format!(
                "Submodule path '{}' is not valid UTF-8 and cannot be staged in its parent",
                node.rel_path.display()
            ))
        })?;

        let repo = match GitOps::open_repo(&node.abs_path) {
            Ok(r) => r,
            Err(e) => {
                Formatter::print_error(&format!("Failed to open '{}': {}", node.label(), e));
                failed += 1;
                continue;
            }
        };

        // Record the gitlinks of any children we already committed in this run.
        // This must happen before the cleanliness check, since these staged
        // gitlinks may be the only thing this repository has to commit.
        let child_links = pending_gitlinks.remove(&node.abs_path).unwrap_or_default();
        if !child_links.is_empty() && !dry_run {
            if let Err(e) = git_backend::stage_paths(&repo, &child_links) {
                Formatter::print_error(&format!(
                    "Failed to record nested submodule commits in '{}': {}",
                    node.label(),
                    e
                ));
                failed += 1;
                continue;
            }
        }

        let is_clean = match GitOps::is_clean(&repo) {
            Ok(c) => c,
            Err(e) => {
                Formatter::print_error(&format!(
                    "Failed to check status of '{}': {}",
                    node.label(),
                    e
                ));
                failed += 1;
                continue;
            }
        };

        if is_clean && child_links.is_empty() && !force_all {
            if verbose {
                Formatter::print_info(&format!("No changes in '{}'", node.label()));
            }
            skipped += 1;
            continue;
        }

        if dry_run {
            if stage_all {
                Formatter::print_info("Would stage all changes (git add -A)");
            }
            if !child_links.is_empty() {
                Formatter::print_info(&format!(
                    "Would record {} nested submodule commit(s)",
                    child_links.len()
                ));
            }
            Formatter::print_info("Would commit changes");
            successful += 1;
            // Keep the dry-run picture honest: the parent would see this gitlink.
            pending_gitlinks
                .entry(node.parent_path.clone())
                .or_default()
                .push(gitlink.to_string());
            continue;
        }

        if stage_all {
            if let Err(e) = git_backend::stage_all(&repo) {
                Formatter::print_error(&format!("Failed to stage in '{}': {}", node.label(), e));
                failed += 1;
                continue;
            }
        }

        match SubmoduleOps::commit(node, message) {
            Ok(_) => {
                Formatter::print_success(&format!("Committed changes in '{}'", node.label()));
                successful += 1;
                pending_gitlinks
                    .entry(node.parent_path.clone())
                    .or_default()
                    .push(gitlink.to_string());

                if push {
                    match SubmoduleOps::push(node, false) {
                        Ok(_) => {
                            Formatter::print_success(&format!(
                                "Pushed changes in '{}'",
                                node.label()
                            ));
                        }
                        Err(e) => {
                            Formatter::print_error(&format!(
                                "Failed to push in '{}': {}",
                                node.label(),
                                e
                            ));
                            failed += 1;
                        }
                    }
                }
            }
            Err(e) => {
                Formatter::print_error(&format!("Failed to commit in '{}': {}", node.label(), e));
                failed += 1;
            }
        }
    }

    if dry_run {
        if pending_gitlinks.contains_key(repo_root.as_ref()) {
            Formatter::print_info("Would update superproject to reference new commits");
        }
        return Ok(());
    }

    // Every parent in the tree has been visited by the loop above, so anything
    // still pending belongs to the fleet root itself.
    let root_links = pending_gitlinks
        .remove(repo_root.as_ref())
        .unwrap_or_default();

    if !pending_gitlinks.is_empty() {
        let orphans: Vec<String> = pending_gitlinks
            .keys()
            .map(|path| path.display().to_string())
            .collect();
        return Err(SuperGitError::Other(format!(
            "Internal error: committed submodules whose parent was never visited: {}. \
             The tree would be left with unrecorded gitlinks.",
            orphans.join(", ")
        )));
    }

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

    // The superproject is a repository too, and this used to run only when a
    // child had moved. A change to a file at the fleet root and nowhere else —
    // `workspace.json`, a document, a version — was staged by nobody and
    // committed by nobody, and the summary said "0 failed": a checkpoint that
    // reported success and left the edit sitting in the working tree.
    //
    // And only when there IS something: a clean superproject whose children
    // did not move is skipped, as a clean submodule is above. Handed to `git
    // commit` anyway, it was never "nothing to commit" — git runs the
    // pre-commit hook first, the hook renders the version a commit being made
    // gets (one more than the clean tree's) and stages it, and git then had a
    // change to commit. Every checkpoint made a superproject commit of nothing
    // but its own version.
    let root_has_changes = !root_links.is_empty() || !GitOps::is_clean(&git_backend::open_repo(repo_root.as_ref())?)?;
    if root_has_changes || force_all {
        let repo = git_backend::open_repo(repo_root.as_ref())?;
        if !root_links.is_empty() {
            Formatter::print_info("Updating superproject...");
            git_backend::stage_paths(&repo, &root_links)?;
        }
        if stage_all {
            git_backend::stage_all(&repo)?;
        }

        // "Update submodules" only when that is what happened. A commit
        // carrying one edited file and no gitlink is not an update of anything
        // below it.
        let subject = if root_links.is_empty() {
            message.to_string()
        } else {
            format!("Update submodules: {}", message)
        };
        match git_backend::commit_all(&repo, &subject) {
            Ok(Some(_)) => {
                Formatter::print_success("Committed in superproject");
                if push {
                    match git_backend::push_current_branch(&repo, Some("origin"), true, false) {
                        Ok(_) => Formatter::print_success("Pushed superproject"),
                        Err(err) => {
                            Formatter::print_error(&format!(
                                "Failed to push superproject: {}",
                                err
                            ));
                            failed += 1;
                        }
                    }
                }
            }
            // Genuinely nothing: no gitlink moved and no file at the root
            // changed. Said only when asked, because that is the ordinary case
            // for a fleet whose work all happens in its submodules.
            Ok(None) => {
                if verbose {
                    Formatter::print_info("Nothing to commit in the superproject");
                }
            }
            Err(err) => {
                Formatter::print_error(&format!("Failed to commit in superproject: {}", err));
                failed += 1;
            }
        }
    } else if verbose {
        Formatter::print_info("Nothing to commit in the superproject");
    }

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

    Ok(())
}

/// Apply `--include` / `--exclude` against a submodule's workspace alias, falling
/// back to its path from the fleet root when it declares no alias.
fn selected(
    node: &SubmoduleNode,
    include: &[String],
    exclude: &[String],
    verbose: bool,
) -> Result<bool> {
    if include.is_empty() && exclude.is_empty() {
        return Ok(true);
    }

    let alias = WorkspaceState::load(&node.abs_path)
        .ok()
        .and_then(|state| state.wstemplate_entry().map(|entry| entry.alias.clone()));

    // A submodule matches on its alias, its name, or its full path from the root,
    // so nested submodules can be addressed unambiguously.
    let matches = |candidate: &String| {
        alias.as_ref() == Some(candidate)
            || &node.name == candidate
            || &node.display_path == candidate
    };

    let label = alias.as_deref().unwrap_or(&node.display_path);

    if !include.is_empty() && !include.iter().any(matches) {
        if verbose {
            Formatter::print_info(&format!("Skipping '{}' (not in --include list)", label));
        }
        return Ok(false);
    }

    if exclude.iter().any(matches) {
        if verbose {
            Formatter::print_info(&format!("Skipping '{}' (excluded)", label));
        }
        return Ok(false);
    }

    Ok(true)
}

#[cfg(test)]
#[path = "commit_tests.rs"]
mod tests;