git-workarea 3.1.2

Simple routines to work with git repositories and set up minimal workareas with them.
Documentation
// Copyright 2016 Kitware, Inc.
//
// 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 error::*;
use prepare::GitWorkArea;

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

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

impl CommitId {
    /// Create a new `CommitId`.
    pub fn new<I: ToString>(id: I) -> Self {
        CommitId(id.to_string())
    }

    /// 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())
    }
}

#[derive(Debug, Clone)]
/// A context for performing git commands.
pub struct GitContext {
    /// The path to the `.git` directory.
    gitdir: PathBuf,
    /// The path to the configuration file.
    config: Option<PathBuf>,
}

#[derive(Debug, PartialEq, Eq)]
/// An identity for creating git commits.
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: ToString,
              E: ToString,
    {
        Identity {
            name: name.to_string(),
            email: email.to_string(),
        }
    }
}

impl Clone for Identity {
    fn clone(&self) -> Self {
        Self::new(&self.name, &self.email)
    }
}

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

#[derive(Debug)]
/// Status of a merge check.
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: AsRef<Path>,
    {
        GitContext {
            gitdir: gitdir.as_ref().to_path_buf(),
            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: AsRef<Path>,
              C: AsRef<Path>,
    {
        GitContext {
            gitdir: gitdir.as_ref().to_path_buf(),
            config: Some(config.as_ref().to_path_buf()),
        }
    }

    /// 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) -> Result<()>
        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()
                .collect::<Vec<_>>())
            .output()
            .chain_err(|| "failed to construct fetch command")?;
        if !fetch.status.success() {
            bail!(ErrorKind::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) -> Result<()>
        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) -> Result<()>
        where R: AsRef<str>,
              N: AsRef<str>,
              T: AsRef<str>,
    {
        self.fetch_into(remote.as_ref(),
                        format!("+{}", refname.as_ref()),
                        target.as_ref())
    }

    /// Create a tree where further work on the given revision can occur.
    pub fn prepare(&self, rev: &CommitId) -> Result<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) -> Result<(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()
                .chain_err(|| "failed to construct for-each-ref command")?;
            if !for_each_ref.status.success() {
                bail!(ErrorKind::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()
                .chain_err(|| "failed to construct update-ref command")?;

            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") {
                bail!(ErrorKind::InvalidRef("no such commit".to_string()));
            } else if err.contains("not a valid SHA1") {
                bail!(ErrorKind::InvalidRef("invalid SHA".to_string()));
            }
        }
    }

    /// 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) -> Result<(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) -> Result<MergeStatus> {
        let merge_base = self.git()
            .arg("merge-base")
            .arg("--all")   // find all merge bases
            .arg(base.as_str())
            .arg(topic.as_str())
            .output()
            .chain_err(|| "failed to construct merge-base command")?;
        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
    }
}