git-shell 0.3.0

This library calls Git via shell, to provide some kit...
Documentation
// License: see LICENSE file at root directory of main branch

//! # Git

use core::{
    str::FromStr,
};

use std::{
    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
    env,
    ffi::OsStr,
    io::{Error, ErrorKind},
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

use {
    crate::Result,
    dia_semver::Semver,
};

mod remote;
mod status;
mod work_tree;

pub use self::{
    remote::*,
    status::*,
    work_tree::*,
};

const APP: &str = "git";

const CMD_ADD: &str = "add";
const CMD_BRANCH: &str = "branch";
const CMD_COMMIT: &str = "commit";
const CMD_PUSH: &str = "push";
const CMD_REMOTE: &str = "remote";
const CMD_STATUS: &str = "status";
const CMD_TAG: &str = "tag";
const CMD_WORKTREE: &str = "worktree";

const OPTION_2_HYPHENS: &str = "--";
const OPTION_ALL: &str = "--all";
const OPTION_ANNOTATE: &str = "--annotate";
const OPTION_MESSAGE: &str = "--message";
const OPTION_SHOW_CURRENT: &str = "--show-current";
const OPTION_TAGS: &str = "--tags";
const OPTION_VERBOSE: &str = "--verbose";

const NULL_LINE_BREAK: char = '\0';

/// # Git
///
/// This struct holds a path to some repository. The path does not need to be root directory of that repository.
#[derive(Debug)]
pub struct Git {

    path: PathBuf,

}

impl Git {

    /// # Makes new instance
    ///
    /// If you don't provide a path, current directory will be used.
    pub fn make<P>(path: Option<P>) -> Result<Self> where P: AsRef<Path> {
        Ok(Self {
            path: match path {
                Some(path) => path.as_ref().to_path_buf(),
                None => env::current_dir()?,
            },
        })
    }

    /// # Path of this repository
    ///
    /// It's *not* always the root directory of the repository.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// # Makes new command
    fn new_cmd<S, S2>(&self, cmd: S, args: Option<&[S2]>) -> Command where S: AsRef<OsStr>, S2: AsRef<OsStr> {
        let mut result = Command::new(APP);
        result.current_dir(&self.path);

        // Command
        result.arg(cmd);

        // Arguments
        if let Some(args) = args {
            for a in args {
                result.arg(a);
            }
        }

        result
    }

    /// # Makes new command for adding all files
    pub fn new_cmd_for_adding_all_files<F, P>(&self, files: Option<F>) -> Command where F: Iterator<Item=P>, P: AsRef<Path> {
        let mut result = self.new_cmd(CMD_ADD, Some(&[OPTION_ALL]));

        if let Some(files) = files {
            result.arg(OPTION_2_HYPHENS);
            for f in files {
                result.arg(f.as_ref());
            }
        }

        result
    }

    /// # Makes new command for committing all files with a message
    pub fn new_cmd_for_committing_all_files_with_a_message<S>(&self, msg: S) -> Command where S: AsRef<str> {
        self.new_cmd(CMD_COMMIT, Some(&[OPTION_ALL, OPTION_MESSAGE, msg.as_ref()]))
    }

    /// # Makes new command for adding an annotated tag with a message
    pub fn new_cmd_for_adding_an_annotated_tag_with_a_message<S>(&self, tag: &Semver, msg: S) -> Command where S: AsRef<str> {
        let tag = tag.to_string();
        self.new_cmd(CMD_TAG, Some(&[tag.as_str(), OPTION_ANNOTATE, OPTION_MESSAGE, msg.as_ref()]))
    }

    /// # Makes new command for pushing to a remote
    pub fn new_cmd_for_pushing_to_a_remote(&self, remote: &Remote) -> Command {
        self.new_cmd(CMD_PUSH, Some(&[remote.name()]))
    }

    /// # Makes new command for pushing tags to a remote
    pub fn new_cmd_for_pushing_tags_to_a_remote(&self, remote: &Remote) -> Command {
        self.new_cmd(CMD_PUSH, Some(&[remote.name(), OPTION_TAGS]))
    }

    /// # Makes new command, runs it and returns its output (stdout)
    fn run_new_cmd<S, S2>(&self, cmd: S, args: Option<&[S2]>) -> Result<String> where S: AsRef<OsStr>, S2: AsRef<OsStr> {
        self.run_cmd(&mut self.new_cmd(cmd, args))
    }

    /// # Runs given command and returns its output (stdout)
    fn run_cmd(&self, cmd: &mut Command) -> Result<String> {
        cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());

        let output = cmd.output()?;
        if output.status.success() {
            Ok(String::from_utf8(output.stdout).map_err(|e| Error::new(ErrorKind::Other, e))?)
        } else {
            Err(Error::new(ErrorKind::Other, format!("{app} returned: {status}", app=APP, status=output.status)))
        }
    }

    /// # Loads current branch
    pub fn current_branch(&self) -> Result<String> {
        let output = self.run_new_cmd(CMD_BRANCH, Some(&[OPTION_SHOW_CURRENT]))?;
        let output = output.trim();
        if output.lines().count() == 1 {
            Ok(output.to_string())
        } else {
            Err(Error::new(ErrorKind::Other, format!("{app} returned: {output}", app=APP, output=output)))
        }
    }

    /// # Finds last version having build metadata in your set
    pub fn find_last_version_with_build_metadata<S>(&self, build_metadata: &[Option<S>]) -> Result<Option<Semver>> where S: AsRef<str> {
        let build_metadata = build_metadata.iter().map(|bm| bm.as_ref().map(|bm| bm.as_ref())).collect::<BTreeSet<_>>();

        let output = self.run_new_cmd::<_, &str>(CMD_TAG, None)?;

        let mut result = None;
        for line in output.lines() {
            if let Ok(version) = Semver::from_str(line.trim()) {
                if build_metadata.contains(&version.build_metadata()) {
                    match result.as_mut() {
                        None => result = Some(version),
                        Some(other) => if &version > other {
                            *other = version;
                        },
                    };
                }
            }
        }

        Ok(result)
    }

    /// # Finds last versions, grouped by build metadata
    pub fn find_last_versions(&self) -> Result<BTreeMap<Option<String>, Semver>> {
        let output = self.run_new_cmd::<_, &str>(CMD_TAG, None)?;

        let mut result = BTreeMap::new();
        for line in output.lines() {
            if let Ok(version) = Semver::from_str(line.trim()) {
                let build_metadata = version.build_metadata().map(|bm| bm.to_string());
                match result.get_mut(&build_metadata) {
                    None => drop(result.insert(build_metadata, version)),
                    Some(v) => if &version > v {
                        *v = version;
                    },
                };
            }
        }

        Ok(result)
    }

    /// # Loads remotes and sort them
    pub fn remotes(&self) -> Result<Vec<Remote>> {
        let output = self.run_new_cmd(CMD_REMOTE, Some(&[OPTION_VERBOSE]))?;

        let mut set = HashSet::with_capacity(9);
        for line in output.lines() {
            let mut parts = line.split_whitespace();
            match (parts.next(), parts.next(), parts.next(), parts.next()) {
                (Some(name), Some(url), Some("(fetch)" | "(push)"), None) => set.insert(Remote::new(name.to_string(), url.to_string())),
                _ => continue,
            };
        }

        let mut result: Vec<_> = set.into_iter().collect();
        result.sort();
        Ok(result)
    }

    /// # Loads work trees
    pub fn work_trees(&self) -> Result<HashSet<WorkTree>> {
        const PREFIX_WORKTREE: &str = "worktree";

        let data = self.run_new_cmd(CMD_WORKTREE, Some(&[work_tree::CMD_LIST, work_tree::OPTION_PORCELAIN, work_tree::OPTION_Z]))?;
        let mut result = HashSet::with_capacity(data.split(NULL_LINE_BREAK).count() / 4);
        for line in data.split(NULL_LINE_BREAK).map(|l| l.trim()) {
            if line.starts_with(PREFIX_WORKTREE) {
                let mut parts = line.split_whitespace();
                match (parts.next(), parts.next(), parts.next()) {
                    (Some(PREFIX_WORKTREE), Some(path), None) => result.insert(WorkTree::make(path)?),
                    // Git docs say the output is stable, so we can return an error here
                    _ => return Err(Error::new(ErrorKind::InvalidData, __!("Invalid format: {:?}", line))),
                };
            }
        }

        Ok(result)
    }

    /// # Loads status
    pub fn status<F, P>(&self, files: Option<F>) -> Result<HashMap<PathBuf, Status>> where F: Iterator<Item=P>, P: AsRef<Path> {
        let mut cmd = self.new_cmd(CMD_STATUS, Some(&[OPTION_PORCELAIN, OPTION_Z]));

        if let Some(files) = files {
            cmd.arg(OPTION_2_HYPHENS);
            for f in files {
                cmd.arg(f.as_ref());
            }
        }

        let data = self.run_cmd(&mut cmd)?;

        let mut result = HashMap::with_capacity(data.split(NULL_LINE_BREAK).count());
        for line in data.split(NULL_LINE_BREAK).map(|l| l.trim()) {
            if let Some(note) = line.split_whitespace().next() {
                result.insert(PathBuf::from(line[note.len()..].trim()), Status::from_str(note)?);
            }
        }

        Ok(result)
    }

}