git-workarea 4.1.0

Simple routines to work with git repositories and set up minimal workareas with them.
Documentation
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use std::borrow::Cow;
use std::ffi::OsStr;
use std::fmt::{self, Display};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use crates::thiserror::Error;

use prepare::{GitWorkArea, WorkAreaResult};

/// The Git object id of a commit.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CommitId(String);

/// Errors which may occur when working with workareas.
///
/// This enum is `non_exhaustive`, but cannot be marked as such until it is stable. In the
/// meantime, there is a hidden variant.
#[derive(Debug, Error)]
// TODO: #[non_exhaustive]
pub enum GitError {
    /// Command preparation failure.
    #[error("failed to construct 'git {}' command", subcommand)]
    Subcommand {
        /// The git subcommand which failed.
        subcommand: &'static str,
        /// The root cause of the failure.
        #[source]
        source: io::Error,
    },
    /// A git error occurred.
    #[error("git error: '{}'", msg)]
    Git {
        /// Message describing what failed.
        msg: Cow<'static, str>,
        /// The root cause of the failure (if available).
        #[source]
        source: Option<io::Error>,
    },
    /// An invalid ref was used.
    #[error("invalid git ref: '{}'", ref_)]
    InvalidRef {
        /// The invalid ref (or description of what was wrong).
        ref_: Cow<'static, str>,
    },
    /// This is here to force `_` matching right now.
    ///
    /// **DO NOT USE**
    #[doc(hidden)]
    #[error("unreachable...")]
    _NonExhaustive,
}

impl GitError {
    /// Convenience method for constructing an error for a git subcommand failure.
    pub fn subcommand(subcommand: &'static str, source: io::Error) -> Self {
        GitError::Subcommand {
            subcommand,
            source,
        }
    }

    pub(crate) fn git<M>(msg: M) -> Self
    where
        M: Into<Cow<'static, str>>,
    {
        GitError::Git {
            msg: msg.into(),
            source: None,
        }
    }

    pub(crate) fn git_with_source<M>(msg: M, source: io::Error) -> Self
    where
        M: Into<Cow<'static, str>>,
    {
        GitError::Git {
            msg: msg.into(),
            source: Some(source),
        }
    }

    pub(crate) fn invalid_ref<R>(ref_: R) -> Self
    where
        R: Into<Cow<'static, str>>,
    {
        GitError::InvalidRef {
            ref_: ref_.into(),
        }
    }
}

pub(crate) type GitResult<T> = Result<T, GitError>;

impl CommitId {
    /// Create a new `CommitId`.
    pub fn new<I>(id: I) -> Self
    where
        I: Into<String>,
    {
        CommitId(id.into())
    }

    /// The commit as a string reference.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl Display for CommitId {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

/// A context for performing git commands.
#[derive(Debug, Clone)]
pub struct GitContext {
    /// The path to the `.git` directory.
    ///
    /// Note that this must not be the path to a checkout. It is treated as a bare repository.
    gitdir: PathBuf,
    /// The path to the configuration file.
    config: Option<PathBuf>,
}

/// An identity for creating git commits.
#[derive(Debug, PartialEq, Eq)]
pub struct Identity {
    /// The name.
    pub name: String,
    /// The email address.
    pub email: String,
}

impl Identity {
    /// Create a new identity.
    pub fn new<N, E>(name: N, email: E) -> Self
    where
        N: Into<String>,
        E: Into<String>,
    {
        Self {
            name: name.into(),
            email: email.into(),
        }
    }
}

impl Clone for Identity {
    fn clone(&self) -> Self {
        // XXX(1.36.0): These can be `&self.<field>` in 1.36.0.
        Self::new(self.name.clone(), self.email.clone())
    }
}

impl Display for Identity {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{} <{}>", self.name, self.email)
    }
}

/// Status of a merge check.
#[derive(Debug)]
pub enum MergeStatus {
    /// The branches do not contain common history.
    NoCommonHistory,
    /// The branch has already been merged.
    AlreadyMerged,
    /// The branch is mergeable with the given hashes as merge bases.
    Mergeable(Vec<CommitId>),
}

impl Display for MergeStatus {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "{}",
            match *self {
                MergeStatus::NoCommonHistory => "no common history",
                MergeStatus::AlreadyMerged => "already merged",
                MergeStatus::Mergeable(_) => "mergeable",
            },
        )
    }
}

impl GitContext {
    /// Create a new context for the given directory.
    pub fn new<P>(gitdir: P) -> Self
    where
        P: Into<PathBuf>,
    {
        Self {
            gitdir: gitdir.into(),
            config: None,
        }
    }

    /// Create a new context for the given directory with git configuration.
    pub fn new_with_config<P, C>(gitdir: P, config: C) -> Self
    where
        P: Into<PathBuf>,
        C: Into<PathBuf>,
    {
        Self {
            gitdir: gitdir.into(),
            config: Some(config.into()),
        }
    }

    /// Run a git command in the context.
    ///
    /// This builds a `Command` with the proper environment to operate within the context.
    pub fn git(&self) -> Command {
        let mut git = Command::new("git");

        git.env("GIT_DIR", &self.gitdir);

        self.config
            .as_ref()
            .map(|config| git.env("GIT_CONFIG", config));

        git
    }

    /// Fetch references from the given remote.
    ///
    /// The remote is interpreted by Git, so it can be a remote or a specific URL.
    pub fn fetch<R, I, N>(&self, remote: R, refnames: I) -> GitResult<()>
    where
        R: AsRef<str>,
        I: IntoIterator<Item = N>,
        N: AsRef<OsStr>,
    {
        let fetch = self
            .git()
            .arg("fetch")
            .arg(remote.as_ref())
            .args(refnames.into_iter())
            .output()
            .map_err(|err| GitError::subcommand("fetch", err))?;
        if !fetch.status.success() {
            return Err(GitError::git(format!(
                "fetch from {} failed: {}",
                remote.as_ref(),
                String::from_utf8_lossy(&fetch.stderr)
            )));
        }

        Ok(())
    }

    /// Fetch a commit from the given remote into a specific local refname.
    pub fn fetch_into<R, N, T>(&self, remote: R, refname: N, target: T) -> GitResult<()>
    where
        R: AsRef<str>,
        N: AsRef<str>,
        T: AsRef<str>,
    {
        self.fetch(
            remote,
            &[&format!("{}:{}", refname.as_ref(), target.as_ref())],
        )
    }

    /// Fetch a commit from the given remote into a specific local refname, allowing rewinds.
    pub fn force_fetch_into<R, N, T>(&self, remote: R, refname: N, target: T) -> GitResult<()>
    where
        R: AsRef<str>,
        N: AsRef<str>,
        T: AsRef<str>,
    {
        self.fetch_into(remote, format!("+{}", refname.as_ref()), target)
    }

    /// Determine the "default branch" for the repository.
    pub fn default_branch(&self) -> GitResult<Option<String>> {
        // Read the configuration value provided for this purpose.
        let default_branch_name = self
            .git()
            .arg("config")
            .arg("--get")
            .arg("init.defaultBranchName")
            .output()
            .map_err(|err| GitError::subcommand("config --get init.defaultBranchName", err))?;
        if default_branch_name.status.success() {
            return Ok(Some(
                String::from_utf8_lossy(&default_branch_name.stdout)
                    .trim()
                    .into(),
            ));
        }

        // Check `origin/HEAD` if it exists to avoid remote access.
        let origin_head = self
            .git()
            .arg("symbolic-ref")
            .arg("--short")
            .arg("refs/remotes/origin/HEAD")
            .output()
            .map_err(|err| GitError::subcommand("symbolic-ref origin/HEAD", err))?;
        if origin_head.status.success() {
            const ORIGIN_PREFIX: &str = "origin/";
            let full_refname = String::from_utf8_lossy(&origin_head.stdout);
            let refname = full_refname.trim();
            if refname.starts_with(ORIGIN_PREFIX) {
                return Ok(Some(refname[ORIGIN_PREFIX.len()..].into()));
            }
        }

        // Ask the remote directly what its default branch is.
        let ls_origin_head = self
            .git()
            .arg("ls-remote")
            .arg("--symref")
            .arg("origin")
            .arg("HEAD")
            .output()
            .map_err(|err| GitError::subcommand("ls-remote --symref origin", err))?;
        if ls_origin_head.status.success() {
            const SYMREF_PREFIX: &str = "ref: refs/heads/";
            const SYMREF_SUFFIX: &str = "\tHEAD";
            let full_output = String::from_utf8_lossy(&ls_origin_head.stdout);
            for line in full_output.lines() {
                if line.starts_with(SYMREF_PREFIX) && line.ends_with(SYMREF_SUFFIX) {
                    let refname = &line[SYMREF_PREFIX.len()..(line.len() - SYMREF_SUFFIX.len())];
                    return Ok(Some(refname.into()));
                }
            }
        }

        // Unknown remote; use the local `HEAD` as the default.
        let head = self
            .git()
            .arg("symbolic-ref")
            .arg("--short")
            .arg("HEAD")
            .output()
            .map_err(|err| GitError::subcommand("symbolic-ref HEAD", err))?;
        if head.status.success() {
            return Ok(Some(String::from_utf8_lossy(&head.stdout).trim().into()));
        }

        Ok(None)
    }

    /// Create a tree where further work on the given revision can occur.
    pub fn prepare(&self, rev: &CommitId) -> WorkAreaResult<GitWorkArea> {
        GitWorkArea::new(self.clone(), rev)
    }

    /// Reserve a refname for the given commit.
    ///
    /// Returns the name of the reserved ref pointing to the given commit and its ID.
    ///
    /// The reserved reference is created as `refs/{name}/heads/{id}` where `id` is a unique
    /// integer (which is also returned).
    pub fn reserve_ref<N>(&self, name: N, commit: &CommitId) -> GitResult<(String, usize)>
    where
        N: AsRef<str>,
    {
        let ref_prefix = format!("refs/{}/heads", name.as_ref());

        debug!(target: "git", "reserving ref under {}", ref_prefix);

        loop {
            let for_each_ref = self
                .git()
                .arg("for-each-ref")
                .arg("--format=%(refname)")
                .arg("--")
                .arg(&ref_prefix)
                .output()
                .map_err(|err| GitError::subcommand("for-each-ref", err))?;
            if !for_each_ref.status.success() {
                return Err(GitError::git(format!(
                    "listing all {} refs: {}",
                    ref_prefix,
                    String::from_utf8_lossy(&for_each_ref.stderr)
                )));
            }
            let refs = String::from_utf8_lossy(&for_each_ref.stdout);

            let nrefs = refs.lines().count();
            let new_ref = format!("{}/{}", ref_prefix, nrefs);

            debug!(target: "git", "trying to reserve ref {}", new_ref);

            let lock_ref = self
                .git()
                .arg("update-ref")
                .arg(&new_ref)
                .arg(commit.as_str())
                .arg("0000000000000000000000000000000000000000")
                .stdout(Stdio::null())
                .output()
                .map_err(|err| GitError::git_with_source("update-ref", err))?;

            if lock_ref.status.success() {
                debug!(target: "git", "successfully reserved {}", new_ref);

                return Ok((new_ref, nrefs));
            }

            let err = String::from_utf8_lossy(&lock_ref.stderr);
            if err.contains("with nonexistent object") {
                return Err(GitError::invalid_ref("no such commit"));
            } else if err.contains("not a valid SHA1") {
                return Err(GitError::invalid_ref("invalid SHA"));
            }
        }
    }

    /// Reserve two refnames for the given commit.
    ///
    /// Returns the names of the two reserved refs, the first pointing to the given commit and the
    /// second available for further work.
    ///
    /// The reserved references are created as `refs/{name}/heads/{id}` and
    /// `refs/{name}/bases/{id}` where the `bases` reference is available to point to an object
    /// associated with the `heads` reference.
    ///
    /// It is assumed that the `bases` refs are aligned with the `heads` references and not used
    /// for other purposes.
    pub fn reserve_refs<N>(&self, name: N, commit: &CommitId) -> GitResult<(String, String)>
    where
        N: AsRef<str>,
    {
        let (new_ref, id) = self.reserve_ref(name.as_ref(), commit)?;
        let new_base = format!("refs/{}/bases/{}", name.as_ref(), id);

        debug!(target: "git", "successfully reserved {} and {}", new_ref, new_base);

        Ok((new_ref, new_base))
    }

    /// Check if a topic commit is mergeable into a target branch.
    pub fn mergeable(&self, base: &CommitId, topic: &CommitId) -> GitResult<MergeStatus> {
        let merge_base = self
            .git()
            .arg("merge-base")
            .arg("--all") // find all merge bases
            .arg(base.as_str())
            .arg(topic.as_str())
            .output()
            .map_err(|err| GitError::subcommand("merge-base", err))?;
        if !merge_base.status.success() {
            return Ok(MergeStatus::NoCommonHistory);
        }
        let bases = String::from_utf8_lossy(&merge_base.stdout);
        let bases = bases
            .split_whitespace()
            .map(CommitId::new)
            .collect::<Vec<_>>();

        Ok(if Some(topic) == bases.first() {
            MergeStatus::AlreadyMerged
        } else {
            MergeStatus::Mergeable(bases)
        })
    }

    /// The path to the git repository.
    pub fn gitdir(&self) -> &Path {
        &self.gitdir
    }
}