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::git_backend;
use crate::prelude::*;

pub struct GitOps;

impl GitOps {
    /// Discover the worktree root for the repository containing `path`.
    pub fn discover_repo_root<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
        git_backend::repo_root(path.as_ref()).map_err(|_| SuperGitError::NotARepository)
    }

    /// Open a repository at the given path
    pub fn open_repo<P: AsRef<Path>>(path: P) -> Result<gix::Repository> {
        git_backend::open_repo(path.as_ref()).map_err(|_| SuperGitError::NotARepository)
    }

    /// Get the URL for a named remote.
    pub fn remote_url(repo: &gix::Repository, remote_name: &str) -> Result<String> {
        git_backend::remote_url(repo, remote_name).map_err(Into::into)
    }

    /// Get current branch name
    pub fn current_branch(repo: &gix::Repository) -> Result<String> {
        git_backend::current_branch(repo).map_err(Into::into)
    }

    /// Check if working directory is clean
    pub fn is_clean(repo: &gix::Repository) -> Result<bool> {
        git_backend::is_clean(repo).map_err(Into::into)
    }

    /// Get list of uncommitted files
    ///
    /// A repository is clean exactly when this is empty, so a caller that needs
    /// both facts should ask this once rather than pairing it with
    /// [`is_clean`](Self::is_clean) — walking a large worktree twice is the
    /// expensive part.
    pub fn get_uncommitted_files(repo: &gix::Repository) -> Result<Vec<String>> {
        Ok(git_backend::status_entries(repo)?
            .into_iter()
            .filter(|entry| entry.status != git_backend::FileStatusKind::Ignored)
            .map(|entry| entry.path)
            .collect())
    }

    /// Uncommitted changes, with moved submodule pointers separated from the
    /// repository's own files: `(own files, moved gitlinks)`.
    ///
    /// A container repository in this fleet holds little but gitlinks, and they
    /// move whenever a child's working tree does — which, in a tree under
    /// `wsb local on`, is every child at once. Counted as ordinary changes they
    /// report `cartridges` as eleven uncommitted files when `git status
    /// --ignore-submodules=all` there shows nothing, and the number tracks the
    /// render rather than anything somebody did.
    ///
    /// They are separated rather than dropped, because a moved gitlink IS a
    /// pending change — the commit recording where the children sit has not been
    /// made — and a status that hid it would misdescribe what a commit from this
    /// tree would contain. One walk answers both, since walking a large worktree
    /// twice is the expensive part.
    pub fn uncommitted_files_and_pointers(
        repo: &gix::Repository,
    ) -> Result<(Vec<String>, Vec<String>)> {
        let (pointers, own): (Vec<_>, Vec<_>) = git_backend::status_entries(repo)?
            .into_iter()
            .filter(|entry| entry.status != git_backend::FileStatusKind::Ignored)
            .partition(|entry| entry.submodule_pointer);
        Ok((
            own.into_iter().map(|entry| entry.path).collect(),
            pointers.into_iter().map(|entry| entry.path).collect(),
        ))
    }

    /// Uncommitted changes to this repository's OWN files.
    ///
    /// A moved gitlink is left out. It is a real pending change — the commit
    /// that records where the children sit has not been made — but it is not an
    /// edit anyone can lose, and for a container repository in this fleet it is
    /// the ordinary state: the children are pulled by the same command that
    /// pulls the container, so their positions differ from the container's HEAD
    /// for most of a fleet pull.
    ///
    /// Treating it as "uncommitted changes" made `pull` skip every container
    /// whose children had moved — `fabricloom` reporting `floom-engine,
    /// floom-uikit, macfloom, and 1 more` while `git status
    /// --ignore-submodules=all` reported nothing at all — so the container's own
    /// commits were never pulled, and the next run skipped it for the same
    /// reason.
    ///
    /// Nothing is waved through: if the incoming commits really would overwrite
    /// something, the pull itself reports it, from the one place that can see
    /// what the merge actually touches.
    pub fn get_uncommitted_own_files(repo: &gix::Repository) -> Result<Vec<String>> {
        Ok(git_backend::status_entries(repo)?
            .into_iter()
            .filter(|entry| entry.status != git_backend::FileStatusKind::Ignored)
            .filter(|entry| !entry.submodule_pointer)
            .map(|entry| entry.path)
            .collect())
    }

    /// Name what makes a repository dirty, for a message the reader can act on.
    ///
    /// Capped so one noisy tree cannot bury the rest of a run's output.
    pub fn describe_uncommitted(paths: &[String]) -> String {
        const SHOWN: usize = 3;
        if paths.len() <= SHOWN {
            paths.join(", ")
        } else {
            format!(
                "{}, and {} more",
                paths[..SHOWN].join(", "),
                paths.len() - SHOWN
            )
        }
    }

    /// Check if branch exists locally
    pub fn branch_exists(repo: &gix::Repository, branch_name: &str) -> Result<bool> {
        git_backend::branch_exists(repo, branch_name, git_backend::BranchKind::Local)
            .map_err(Into::into)
    }

    /// Check if remote branch exists
    pub fn remote_branch_exists(repo: &gix::Repository, branch_name: &str) -> Result<bool> {
        let remote_branch = format!("origin/{branch_name}");
        git_backend::branch_exists(repo, &remote_branch, git_backend::BranchKind::Remote)
            .map_err(Into::into)
    }

    /// Get ahead/behind counts for current branch
    pub fn get_ahead_behind(repo: &gix::Repository) -> Result<(usize, usize)> {
        let head = repo.head().map_err(anyhow::Error::from)?;
        let branch = match head.referent_name() {
            Some(name) => name,
            None => return Ok((0, 0)),
        };
        let tracking =
            match repo.branch_remote_tracking_ref_name(branch, gix::remote::Direction::Fetch) {
                Some(Ok(name)) => name,
                Some(Err(err)) => return Err(anyhow::Error::from(err).into()),
                None => return Ok((0, 0)),
            };
        let head_id = repo.head_id().map_err(anyhow::Error::from)?.detach();
        let upstream_id = match repo.find_reference(tracking.as_ref()) {
            Ok(mut reference) => reference
                .peel_to_id()
                .map_err(anyhow::Error::from)?
                .detach(),
            Err(_) => return Ok((0, 0)),
        };
        let merge_base = repo
            .merge_base(head_id, upstream_id)
            .map_err(anyhow::Error::from)?;
        let ahead = count_commits_between(repo, head_id, merge_base.into())?;
        let behind = count_commits_between(repo, upstream_id, merge_base.into())?;
        Ok((ahead, behind))
    }

    /// Fetch from remote
    pub fn fetch(repo: &gix::Repository, remote_name: &str) -> Result<()> {
        git_backend::fetch(repo, Some(remote_name)).map_err(Into::into)
    }

    /// Check if repository is in detached HEAD state
    pub fn is_detached_head(repo: &gix::Repository) -> Result<bool> {
        git_backend::is_detached_head(repo).map_err(Into::into)
    }

    /// Get list of all local branches
    pub fn get_local_branches(repo: &gix::Repository) -> Result<Vec<String>> {
        git_backend::local_branches(repo).map_err(Into::into)
    }

    /// Get list of all remote branches
    pub fn get_remote_branches(repo: &gix::Repository) -> Result<Vec<String>> {
        git_backend::remote_branches(repo).map_err(Into::into)
    }

    /// Get the default branch name (main or master)
    pub fn get_default_branch(repo: &gix::Repository) -> Result<String> {
        git_backend::get_default_branch(repo).map_err(Into::into)
    }
}

fn count_commits_between(
    repo: &gix::Repository,
    from: gix::ObjectId,
    stop_at: gix::ObjectId,
) -> Result<usize> {
    let commit = repo.find_commit(from).map_err(anyhow::Error::from)?;
    let mut count = 0usize;
    for ancestor in commit.ancestors().all().map_err(anyhow::Error::from)? {
        let ancestor = ancestor.map_err(anyhow::Error::from)?;
        if ancestor.id == stop_at {
            break;
        }
        count += 1;
    }
    Ok(count)
}