wsb 0.275.7622

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, SubmoduleTree};
use crate::fleet::output::Formatter;
use crate::git_backend;
use crate::prelude::*;

/// What one repository's pull came to, in the three shapes a summary counts.
///
/// `Blocked` is the state this command used to have no word for: there ARE
/// commits to take and they cannot be taken by fast-forward. Counting it as
/// "skipped" is what let `wsb pull` print a green summary over a superproject
/// that stayed behind.
pub enum Verdict {
    Changed(String),
    Unchanged(String),
    Blocked(String),
}

/// Pull one repository and say what happened, in that repository's name.
///
/// Every message here is wsb's own. Git's text — "error: Your local changes to
/// the following files would be overwritten by merge ... Please commit your
/// changes or stash them before you merge" — describes a git command nobody
/// ran and offers remedies this tool does not use; what the operator needs is
/// which files are in the way and which flag deals with them.
pub fn pull_one(
    repo: &gix::Repository,
    label: &str,
    options: git_backend::PullOptions,
) -> std::result::Result<Verdict, String> {
    match git_backend::pull(repo, options) {
        Ok(git_backend::PullOutcome::FastForwarded {
            commits,
            discarded,
            restored,
        }) => {
            let mut message = format!("Pulled {commits} commit(s) in '{label}'");
            if !discarded.is_empty() {
                message.push_str(&format!(
                    " — discarded local changes to {}",
                    discarded.join(", ")
                ));
            }
            if !restored.is_empty() {
                message.push_str(&format!(
                    " — set aside and restored your changes to {}",
                    restored.join(", ")
                ));
            }
            Ok(Verdict::Changed(message))
        }
        Ok(git_backend::PullOutcome::Rebased { commits }) => Ok(Verdict::Changed(format!(
            "Rebased '{label}' onto {commits} incoming commit(s)"
        ))),
        Ok(git_backend::PullOutcome::UpToDate) => {
            Ok(Verdict::Unchanged(format!("'{label}' is up to date")))
        }
        Ok(git_backend::PullOutcome::Ahead { outgoing }) => Ok(Verdict::Unchanged(format!(
            "'{label}' is up to date ({outgoing} local commit(s) to push)"
        ))),
        Ok(git_backend::PullOutcome::NoRemoteBranch) => Ok(Verdict::Unchanged(format!(
            "'{label}' has no counterpart branch on origin"
        ))),
        Ok(git_backend::PullOutcome::Diverged {
            incoming,
            outgoing,
        }) => Ok(Verdict::Blocked(format!(
            "'{label}' has diverged: {incoming} incoming commit(s) and {outgoing} local \
             commit(s). Fast-forward is impossible — rerun with `wsb pull --rebase` to \
             replay the local commits on top, or merge them by hand."
        ))),
        Ok(git_backend::PullOutcome::LocalChangesBlock {
            incoming,
            paths,
            untracked,
        }) => {
            let mut message = format!(
                "'{label}': {incoming} incoming commit(s) rewrite {} file(s) this tree has \
                 changed:\n      {}",
                paths.len() + untracked.len(),
                paths
                    .iter()
                    .chain(untracked.iter())
                    .cloned()
                    .collect::<Vec<_>>()
                    .join("\n      ")
            );
            if untracked.is_empty() {
                message.push_str(
                    "\n    Rerun with `wsb pull --autostash` to set those changes aside and \
                     put them back on top, or `wsb pull --overwrite-local` to throw them \
                     away. Nothing else in the tree is touched either way.",
                );
            } else {
                // Neither flag applies: git has no copy of an untracked file,
                // so setting it aside or discarding it would be unrecoverable.
                message.push_str(&format!(
                    "\n    {} untracked, so neither --autostash nor --overwrite-local \
                     applies to {}: git holds no copy to put back. Move or remove {} by \
                     hand.",
                    if untracked.len() == 1 {
                        "One of these is"
                    } else {
                        "Some of these are"
                    },
                    untracked.join(", "),
                    if untracked.len() == 1 { "it" } else { "them" }
                ));
            }
            Ok(Verdict::Blocked(message))
        }
        Ok(git_backend::PullOutcome::AutostashConflict {
            incoming,
            paths,
            stash,
        }) => Ok(Verdict::Blocked(format!(
            "'{label}': took {incoming} incoming commit(s), but your changes to {} could \
             not be replayed on top of them — the same lines moved on both sides. Your \
             work is safe in {stash}; `git stash show -p {stash}` shows it and \
             `git checkout {stash} -- <file>` takes a side.",
            paths.join(", ")
        ))),
        Err(err) => Err(format!("Failed to pull in '{label}': {err}")),
    }
}

pub fn execute<P: AsRef<Path>>(
    repo_root: P,
    options: git_backend::PullOptions,
    dry_run: bool,
    verbose: bool,
) -> Result<()> {
    let tree = SubmoduleTree::discover(&repo_root)?;

    Formatter::print_info(&format!(
        "Pulling latest changes in superproject + {} submodule(s){}",
        tree.len(),
        if options.rebase { " (with rebase)" } else { "" }
    ));

    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;

    // Pull superproject first
    if verbose {
        Formatter::print_submodule_header("[SUPERPROJECT]");
    }

    {
        let repo = GitOps::open_repo(&repo_root)?;
        if dry_run {
            // A dry run still fetches: what a pull WOULD do cannot be said
            // without knowing where the branch stands, and the previous
            // version answered that question from a stale tracking ref.
            let state = git_backend::remote_state(&repo, Some("origin"))?;
            match state {
                git_backend::RemoteState::Behind { incoming } => {
                    Formatter::print_info(&format!(
                        "Would fast-forward superproject {incoming} commit(s)"
                    ));
                    successful += 1;
                }
                git_backend::RemoteState::Diverged { .. } if options.rebase => {
                    Formatter::print_info(&format!(
                        "Would rebase superproject ({})",
                        state.describe()
                    ));
                    successful += 1;
                }
                git_backend::RemoteState::Diverged { .. } => {
                    Formatter::print_error(&format!("Superproject {}", state.describe()));
                    failed += 1;
                }
                other => {
                    Formatter::print_info(&format!("Superproject {}", other.describe()));
                    skipped += 1;
                }
            }
        } else {
            match pull_one(&repo, "superproject", options) {
                Ok(Verdict::Changed(message)) => {
                    Formatter::print_success(&message);
                    successful += 1;
                }
                Ok(Verdict::Unchanged(message)) => {
                    Formatter::print_info(&message);
                    skipped += 1;
                }
                Ok(Verdict::Blocked(message)) | Err(message) => {
                    Formatter::print_error(&message);
                    failed += 1;
                }
            }
        }
    }

    // Parent before child: pulling a parent can move the gitlink a child is
    // expected to sit at, so the parent's new state must land first.
    for node in tree.top_down() {
        if verbose {
            Formatter::print_submodule_header(node.label());
        }

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

        // Check for uncommitted changes
        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;
            }
        };

        // A submodule with changes of its own is pulled exactly as the
        // superproject is: `git_backend::pull` takes the incoming commits when
        // they touch nothing changed here, and otherwise blocks naming the
        // files, or sets them aside (`--autostash`) or drops them
        // (`--overwrite-local`) as asked. A blanket skip here left every
        // submodule whose rendered version file had moved behind `main` after a
        // ship had joined its commits to it — the one moment pulling matters.

        // Check if in detached HEAD
        let is_detached = match GitOps::is_detached_head(&repo) {
            Ok(d) => d,
            Err(e) => {
                Formatter::print_error(&format!(
                    "Failed to check HEAD state of '{}': {}",
                    node.label(),
                    e
                ));
                failed += 1;
                continue;
            }
        };

        if is_detached {
            Formatter::print_warning(&format!(
                "Submodule '{}' is in detached HEAD state, skipping",
                node.label()
            ));
            skipped += 1;
            continue;
        }

        if dry_run {
            match git_backend::remote_state(&repo, Some("origin")) {
                Ok(state) if state.has_incoming() => {
                    Formatter::print_info(&format!(
                        "Would pull '{}' ({})",
                        node.label(),
                        state.describe()
                    ));
                    successful += 1;
                }
                Ok(state) => {
                    if verbose {
                        Formatter::print_info(&format!(
                            "'{}' {}",
                            node.label(),
                            state.describe()
                        ));
                    }
                    skipped += 1;
                }
                Err(e) => {
                    Formatter::print_error(&format!(
                        "Failed to determine the state of '{}': {}",
                        node.label(),
                        e
                    ));
                    failed += 1;
                }
            }
            continue;
        }

        match pull_one(&repo, node.label(), options) {
            Ok(Verdict::Changed(message)) => {
                Formatter::print_success(&message);
                successful += 1;
            }
            Ok(Verdict::Unchanged(message)) => {
                if verbose {
                    Formatter::print_info(&message);
                }
                skipped += 1;
            }
            Ok(Verdict::Blocked(message)) | Err(message) => {
                Formatter::print_error(&message);
                failed += 1;
            }
        }
    }

    if !dry_run {
        Formatter::print_summary(successful, failed, skipped);
    }

    if failed > 0 {
        return Err(SuperGitError::Other(format!(
            // "submodule" was wrong for the one repository that is not one:
            // the superproject's own failure was reported as a submodule's.
            "{} repository/repositories failed to pull",
            failed
        )));
    }

    Ok(())
}

/// Take every repository's incoming commits before a checkpoint commits over
/// them, parents first.
///
/// A ship joins its regenerations and re-pins to `main` when it ends, so the
/// checkpoint after one meets a `main` that moved in every repository the
/// ship touched — and each of those holds rendered files of its own that the
/// incoming commits rewrite (`version.txt`, a manifest, its lock). A rendered
/// file is the render's: the commit below renders it again, so a local change
/// to one is dropped to let the commits in. A change to any OTHER file they
/// rewrite is somebody's work, and stops the checkpoint here, naming it, with
/// nothing committed. So does a branch with commits of its own that `main`
/// moved past.
pub fn bring_main_in(project_root: &Path, dry_run: bool) -> std::result::Result<(), String> {
    use crate::git_backend::{PullOptions, PullOutcome};
    let tree = crate::fleet::git::tree::SubmoduleTree::discover_allow_empty(project_root)
        .map_err(|e| format!("discovering submodules under {}: {e}", project_root.display()))?;
    let mut repositories = vec![project_root.to_path_buf()];
    repositories.extend(tree.top_down().iter().filter(|node| node.initialized).map(|node| node.abs_path.clone()));
    for repository in &repositories {
        let label = match repository.strip_prefix(project_root) {
            Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
            Ok(relative) => relative.display().to_string(),
            Err(_) => repository.display().to_string(),
        };
        let repo = crate::git_backend::open_repo(repository).map_err(|e| format!("{label}: {e}"))?;
        // A repository with no remote has no `main` but its own to bring in —
        // the state a branch with no counterpart on its remote already is.
        if repo.find_remote("origin").is_err() {
            continue;
        }
        if dry_run {
            let state = crate::git_backend::remote_state(&repo, Some("origin")).map_err(|e| format!("{label}: {e}"))?;
            if state.has_incoming() {
                crate::outln!("  {label}: would take {}", state.describe());
            }
            continue;
        }
        match crate::git_backend::pull(&repo, PullOptions::default()).map_err(|e| format!("{label}: {e}"))? {
            PullOutcome::FastForwarded { commits, .. } => crate::outln!("  {label}: took {commits} commit(s)"),
            PullOutcome::UpToDate | PullOutcome::Ahead { .. } | PullOutcome::NoRemoteBranch => {}
            PullOutcome::Rebased { .. } | PullOutcome::AutostashConflict { .. } => {
                return Err(format!("{label}: a plain pull rebased or stashed, which this checkpoint did not ask for"))
            }
            PullOutcome::Diverged { incoming, outgoing } => return Err(format!(
                "{label}: {incoming} commit(s) on main and {outgoing} of its own not pushed — main moved past \
                 work here. Nothing was committed; bring them together with `wsb pull --rebase`, then checkpoint."
            )),
            PullOutcome::LocalChangesBlock { paths, untracked, .. } => {
                let rendered = crate::state::state_common::files_an_update_writes(repository).map_err(|e| format!("{label}: {e:#}"))?;
                let place = crate::platform::canonicalize(repository)
                    .map_err(|e| format!("resolving {}: {e}", repository.display()))?;
                let somebody_s: Vec<&String> = paths
                    .iter()
                    .filter(|path| !rendered.contains(&place.join(path)))
                    .chain(untracked.iter())
                    .collect();
                if !somebody_s.is_empty() {
                    return Err(format!(
                        "{label}: the commits on main rewrite {}, changed here and not rendered — somebody's \
                         work. Nothing was committed; commit or set it aside, then checkpoint.",
                        somebody_s.iter().map(|p| p.as_str()).collect::<Vec<_>>().join(", ")
                    ));
                }
                let dropping = PullOptions { overwrite_local: true, ..Default::default() };
                match crate::git_backend::pull(&repo, dropping).map_err(|e| format!("{label}: {e}"))? {
                    PullOutcome::FastForwarded { commits, discarded, .. } => crate::outln!(
                        "  {label}: took {commits} commit(s), dropping the rendered {} — the commit renders it again",
                        discarded.join(", ")
                    ),
                    other => return Err(format!("{label}: dropping only rendered files did not let main in: {other:?}")),
                }
            }
        }
    }
    Ok(())
}