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::operations::GitOps;
use crate::fleet::git::tree::{SubmoduleNode, SubmoduleTree, MAX_SUBMODULE_DEPTH};
use crate::git_backend;
use crate::prelude::*;

#[derive(Debug, Clone)]
pub struct SubmoduleStatus {
    pub name: String,
    pub path: String,
    pub current_branch: Option<String>,
    pub is_clean: bool,
    pub is_detached: bool,
    pub ahead: usize,
    pub behind: usize,
    /// Files somebody changed. Generated files that stand exactly as a render
    /// would write them are NOT here — they are counted in
    /// [`rendered_files`](Self::rendered_files) instead, because a tree under
    /// `wsb local on` carries one in nearly every repository and reporting them
    /// as changes buries the ones a person made.
    pub uncommitted_files: Vec<String>,
    /// Generated files whose working-tree content IS the render. Counted apart
    /// rather than dropped: the tree really does differ from HEAD here, and a
    /// status that said nothing about it would be lying about what a commit
    /// from this tree would contain.
    pub rendered_files: Vec<String>,
    /// Submodule pointers this repository records at a different commit than
    /// its children are standing on. Counted apart for the same reason as
    /// `rendered_files`: in a container repository they move whenever a child's
    /// tree does, so as ordinary changes they report eleven files where
    /// `--ignore-submodules=all` shows none.
    pub moved_pointers: Vec<String>,
    pub exists: bool,
    /// Nesting level from the fleet root; the superproject is 0 and its direct
    /// submodules are 1. Drives indentation in the status output.
    pub depth: usize,
}

/// Outcome of initializing a submodule tree.
#[derive(Debug, Default)]
pub struct InitOutcome {
    /// Display paths of submodules that were checked out by this run.
    pub initialized: Vec<String>,
    /// Submodules that could not be checked out, paired with the reason.
    pub failures: Vec<(String, SuperGitError)>,
}

/// Divide a repository's changed paths into the ones a person made and the
/// ones a render wrote.
///
/// Git reports paths relative to the repository; `rendered` holds absolute
/// ones, because it answers for a whole nesting tree in which the same relative
/// path occurs in a dozen repositories. Joining here is what keeps
/// `cartridges/pdfcartridge/Cargo.toml` from being settled by the render of
/// `capdag/capdag-rs/Cargo.toml`.
pub fn split_rendered(
    repo_root: &Path,
    changed: Vec<String>,
    rendered: &std::collections::HashSet<PathBuf>,
) -> (Vec<String>, Vec<String>) {
    changed
        .into_iter()
        .partition(|path| !rendered.contains(&repo_root.join(path)))
}

pub struct SubmoduleOps;

impl SubmoduleOps {
    /// Get status for a single submodule at any depth.
    ///
    /// `rendered` is the fleet-wide set of generated files that currently stand
    /// exactly as a render would write them (see
    /// [`crate::state::discard::rendered_as_written`]). It is computed once for
    /// the whole walk and passed in, because it is a property of the worktree
    /// rather than of any one repository — deriving it per submodule would
    /// re-render the fleet once per node.
    pub fn get_status(
        node: &SubmoduleNode,
        rendered: &std::collections::HashSet<PathBuf>,
    ) -> Result<SubmoduleStatus> {
        if !node.initialized {
            return Ok(SubmoduleStatus {
                name: node.display_path.clone(),
                path: node.display_path.clone(),
                current_branch: None,
                is_clean: true,
                is_detached: false,
                ahead: 0,
                behind: 0,
                uncommitted_files: Vec::new(),
                rendered_files: Vec::new(),
                moved_pointers: Vec::new(),
                exists: false,
                depth: node.depth + 1,
            });
        }

        let repo = GitOps::open_repo(&node.abs_path)?;

        let is_detached = GitOps::is_detached_head(&repo)?;
        let current_branch = if is_detached {
            None
        } else {
            Some(GitOps::current_branch(&repo)?)
        };

        // A single status walk answers both questions; asking separately walks
        // the whole worktree twice, once per submodule.
        let (changed, moved_pointers) = GitOps::uncommitted_files_and_pointers(&repo)?;
        let (uncommitted_files, rendered_files) =
            split_rendered(&node.abs_path, changed, rendered);
        // Clean means a person left nothing behind. A repository holding only
        // the render is reported as what it is on its own line, and does not
        // raise the indicator that asks somebody to look.
        let is_clean = uncommitted_files.is_empty();

        let (ahead, behind) = if !is_detached {
            GitOps::get_ahead_behind(&repo).unwrap_or((0, 0))
        } else {
            (0, 0)
        };

        Ok(SubmoduleStatus {
            name: node.display_path.clone(),
            path: node.display_path.clone(),
            current_branch,
            is_clean,
            is_detached,
            ahead,
            behind,
            uncommitted_files,
            rendered_files,
            moved_pointers,
            exists: true,
            depth: node.depth + 1,
        })
    }

    /// Clone a single submodule and check out the commit its parent records.
    ///
    /// This initializes exactly one level. Nested children only become visible
    /// once this checkout exists, so recursion is driven by
    /// [`SubmoduleOps::init_tree`], which re-discovers between passes.
    pub fn init(node: &SubmoduleNode) -> Result<()> {
        let parent = node.abs_path.parent().ok_or_else(|| {
            SuperGitError::Other(format!(
                "Submodule path '{}' does not have a parent directory",
                node.display_path
            ))
        })?;
        std::fs::create_dir_all(parent).map_err(|err| {
            SuperGitError::Other(format!(
                "Failed to create parent directory '{}': {}",
                parent.display(),
                err
            ))
        })?;

        let super_repo = git_backend::open_repo(&node.parent_path)?;
        let gitlink_commit = super_repo
            .head_commit()
            .map_err(|err| SuperGitError::Other(err.to_string()))?
            .tree()
            .map_err(|err| SuperGitError::Other(err.to_string()))?
            .lookup_entry_by_path(&node.rel_path)
            .map_err(|err| SuperGitError::Other(err.to_string()))?
            .ok_or_else(|| {
                SuperGitError::Other(format!(
                    "Submodule path '{}' is not present in the current tree",
                    node.display_path
                ))
            })?;

        if !gitlink_commit.mode().is_commit() {
            return Err(SuperGitError::Other(format!(
                "Path '{}' is not a gitlink entry in its superproject",
                node.display_path
            )));
        }

        let url = node.resolved_url()?;
        let repo = git_backend::clone_repository_in(&url, &node.abs_path, &node.parent_path)?;
        git_backend::checkout_commit(&repo, gitlink_commit.id().detach())?;

        Ok(())
    }

    /// Initialize every uninitialized submodule under `root`.
    ///
    /// Each pass re-discovers the tree, so submodules that only became visible
    /// because their parent was just cloned are picked up by the next pass. With
    /// `recursive` false only the fleet root's direct submodules are touched.
    ///
    /// Reports both the submodules that were initialized and any per-submodule
    /// failures, so callers can surface each without losing the other.
    pub fn init_tree<P: AsRef<Path>>(
        root: P,
        recursive: bool,
        mut on_init: impl FnMut(&SubmoduleNode),
    ) -> Result<InitOutcome> {
        let root = root.as_ref();
        let mut outcome = InitOutcome::default();
        // Paths we already tried and failed on, so a re-discovery pass does not
        // retry them forever.
        let mut attempted: std::collections::HashSet<String> = std::collections::HashSet::new();

        for _ in 0..=MAX_SUBMODULE_DEPTH {
            let tree = SubmoduleTree::discover_allow_empty(root)?;

            let pending: Vec<SubmoduleNode> = tree
                .top_down()
                .iter()
                .filter(|node| !node.initialized)
                .filter(|node| recursive || node.depth == 0)
                .filter(|node| !attempted.contains(&node.display_path))
                .cloned()
                .collect();

            if pending.is_empty() {
                return Ok(outcome);
            }

            for node in &pending {
                attempted.insert(node.display_path.clone());
                on_init(node);
                match Self::init(node) {
                    Ok(()) => outcome.initialized.push(node.display_path.clone()),
                    Err(err) => outcome.failures.push((node.display_path.clone(), err)),
                }
            }

            if !recursive {
                return Ok(outcome);
            }
        }

        Err(SuperGitError::Other(format!(
            "Submodule initialization did not converge within {MAX_SUBMODULE_DEPTH} passes at \
             '{}' — check .gitmodules for a cycle",
            root.display()
        )))
    }

    /// Checkout a branch in a submodule.
    pub fn checkout(node: &SubmoduleNode, branch: &str, create: bool) -> Result<()> {
        let repo = Self::open(node)?;
        if create {
            git_backend::create_branch(&repo, branch, None)?;
        } else {
            git_backend::checkout_branch(&repo, branch)?;
        }

        Ok(())
    }

    /// Pull latest changes in a submodule, and say what happened.
    ///
    /// The outcome is returned rather than flattened into `Ok(())`: a branch
    /// that has diverged took nothing, and a caller that cannot tell that from
    /// a successful pull reports the submodule as synced when it is not.
    pub fn pull(
        node: &SubmoduleNode,
        options: git_backend::PullOptions,
    ) -> Result<git_backend::PullOutcome> {
        let repo = Self::open(node)?;
        git_backend::pull(&repo, options).map_err(|err| {
            SuperGitError::Other(format!(
                "Failed to pull in '{}': {}",
                node.display_path, err
            ))
        })
    }

    /// Push changes in a submodule.
    ///
    /// Always sets the upstream: fleet commands routinely push branches they just
    /// created, which have no tracking ref yet, and plain `git push` refuses those.
    /// Setting it again on an already-tracked branch is a no-op.
    pub fn push(node: &SubmoduleNode, force_with_lease: bool) -> Result<()> {
        let repo = Self::open(node)?;
        git_backend::push_current_branch(&repo, Some("origin"), true, force_with_lease)?;
        Ok(())
    }

    /// Commit changes in a submodule.
    pub fn commit(node: &SubmoduleNode, message: &str) -> Result<()> {
        let repo = Self::open(node)?;
        git_backend::commit_all(&repo, message)?;

        Ok(())
    }

    /// Create a new branch in a submodule.
    pub fn create_branch(
        node: &SubmoduleNode,
        branch: &str,
        from_branch: Option<&str>,
    ) -> Result<()> {
        let repo = Self::open(node)?;
        git_backend::create_branch(&repo, branch, from_branch)?;

        Ok(())
    }

    /// Delete a branch in a submodule.
    pub fn delete_branch(node: &SubmoduleNode, branch: &str, force: bool) -> Result<()> {
        let repo = Self::open(node)?;
        git_backend::delete_local_branch(&repo, branch, force)?;

        Ok(())
    }

    /// Delete remote branch in a submodule.
    pub fn delete_remote_branch(node: &SubmoduleNode, branch: &str) -> Result<()> {
        let repo = Self::open(node)?;
        git_backend::delete_remote_branch(&repo, Some("origin"), branch)?;
        Ok(())
    }

    /// Stage all changes in a submodule (git add -A).
    pub fn stage(node: &SubmoduleNode) -> Result<()> {
        let repo = Self::open(node)?;
        git_backend::stage_all(&repo)?;

        Ok(())
    }

    /// Merge a branch into the current branch using fast-forward only.
    pub fn merge_ff(node: &SubmoduleNode, branch: &str) -> Result<()> {
        let repo = Self::open(node)?;
        git_backend::merge_ff(&repo, branch)?;

        Ok(())
    }

    /// Fetch in a submodule.
    pub fn fetch(node: &SubmoduleNode, all_remotes: bool) -> Result<()> {
        let repo = Self::open(node)?;
        if all_remotes {
            git_backend::fetch_all(&repo)?;
        } else {
            git_backend::fetch(&repo, Some("origin"))?;
        }

        Ok(())
    }

    fn open(node: &SubmoduleNode) -> Result<gix::Repository> {
        if !node.initialized {
            return Err(SuperGitError::SubmoduleNotFound(node.display_path.clone()));
        }
        git_backend::open_repo(&node.abs_path).map_err(|err| {
            SuperGitError::Other(format!("Failed to open '{}': {}", node.display_path, err))
        })
    }
}