git-checks-core 1.0.1

Checks to run against a topic in git to enforce coding standards.
Documentation
// Copyright 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 std::fmt::{self, Debug};
use std::iter;
use std::slice;

use crates::git_workarea::{CommitId, GitContext, GitError, Identity, WorkAreaError};
use crates::rayon::prelude::*;
use crates::thiserror::Error;

use check::{BranchCheck, Check, CheckResult, TopicCheck};
use commit::{Commit, CommitError, Topic};
use context::CheckGitContext;

/// Errors which can occur when running checks.
#[derive(Debug, Error)]
// TODO: #[non_exhaustive]
pub enum RunError {
    /// Command preparation failure.
    #[error("git error: {}", source)]
    Git {
        /// The cause of the error.
        #[from]
        source: GitError,
    },
    /// An error occurred when working with the workarea.
    #[error("git workarea error: {}", source)]
    WorkArea {
        /// The cause of the error.
        #[from]
        source: WorkAreaError,
    },
    /// An error occurred when working with a commit.
    #[error("commit error: {}", source)]
    Commit {
        /// The cause of the error.
        #[from]
        source: CommitError,
    },
    /// Failure to create a ref to refer to the checked commit.
    #[error("run check error: failed to update the {} ref: {}", base_ref, output)]
    UpdateRef {
        /// The base name of the ref.
        base_ref: CommitId,
        /// Git's output for the error.
        output: String,
    },
    /// Failure to list revisions to check.
    #[error(
        "run check error: failed to list refs from {} to {}",
        base_ref,
        new_ref
    )]
    RevList {
        /// The base ref for the topic.
        base_ref: CommitId,
        /// The head of the topic.
        new_ref: CommitId,
        /// Git's output for the error.
        output: String,
    },
    /// This is here to force `_` matching right now.
    ///
    /// **DO NOT USE**
    #[doc(hidden)]
    #[error("unreachable...")]
    _NonExhaustive,
}

impl RunError {
    fn update_ref(base_ref: CommitId, output: &[u8]) -> Self {
        RunError::UpdateRef {
            base_ref,
            output: String::from_utf8_lossy(output).into(),
        }
    }

    fn rev_list(base_ref: CommitId, new_ref: CommitId, output: &[u8]) -> Self {
        RunError::RevList {
            base_ref,
            new_ref,
            output: String::from_utf8_lossy(output).into(),
        }
    }
}

/// Configuration for checks to run against a repository.
#[derive(Default, Clone)]
pub struct GitCheckConfiguration<'a> {
    /// Checks to run on each commit.
    checks: Vec<&'a dyn Check>,
    /// Checks to run on the branch as a whole.
    checks_branch: Vec<&'a dyn BranchCheck>,
    /// Checks to run on the topic.
    checks_topic: Vec<&'a dyn TopicCheck>,
}

/// Results from checking a topic.
#[derive(Debug)]
pub struct TopicCheckResult {
    /// The results for each commit in the topic.
    commit_results: Vec<(CommitId, CheckResult)>,
    /// The results for the branch as a whole.
    topic_result: CheckResult,
}

impl TopicCheckResult {
    /// The results for each commit checked.
    pub fn commit_results(&self) -> slice::Iter<(CommitId, CheckResult)> {
        self.commit_results.iter()
    }

    /// The results for the topic as a whole.
    pub fn topic_result(&self) -> &CheckResult {
        &self.topic_result
    }
}

impl From<TopicCheckResult> for CheckResult {
    fn from(res: TopicCheckResult) -> Self {
        res.commit_results
            .into_iter()
            .map(|(_, result)| result)
            .chain(iter::once(res.topic_result))
            .fold(Self::new(), Self::combine)
    }
}

impl<'a> GitCheckConfiguration<'a> {
    /// Create a new check configuration.
    pub fn new() -> Self {
        GitCheckConfiguration {
            checks: vec![],
            checks_branch: vec![],
            checks_topic: vec![],
        }
    }

    /// Add a check to be run on every commit.
    pub fn add_check(&mut self, check: &'a dyn Check) -> &mut Self {
        self.checks.push(check);

        self
    }

    /// Add a check to be once for the entire branch.
    pub fn add_branch_check(&mut self, check: &'a dyn BranchCheck) -> &mut Self {
        self.checks_branch.push(check);

        self
    }

    /// Add a check to be once for the entire topic.
    pub fn add_topic_check(&mut self, check: &'a dyn TopicCheck) -> &mut Self {
        self.checks_topic.push(check);

        self
    }

    /// Find refs that should be checked given a target branch and the topic names.
    fn list(
        &self,
        ctx: &GitContext,
        reason: &str,
        base_branch: &CommitId,
        topic: &CommitId,
    ) -> Result<Vec<CommitId>, RunError> {
        let (new_ref, base_ref) = ctx.reserve_refs(&format!("check/{}", reason), topic)?;
        let update_ref = ctx
            .git()
            .arg("update-ref")
            .args(&["-m", reason])
            .arg(&base_ref)
            .arg(base_branch.as_str())
            .output()
            .map_err(|err| GitError::subcommand("update-ref", err))?;
        if !update_ref.status.success() {
            return Err(RunError::update_ref(
                CommitId::new(base_ref),
                &update_ref.stderr,
            ));
        }

        let rev_list = ctx
            .git()
            .arg("rev-list")
            .arg("--reverse")
            .arg("--topo-order")
            .arg(&new_ref)
            .arg(&format!("^{}", base_ref))
            .output()
            .map_err(|err| GitError::subcommand("rev-list", err))?;
        if !rev_list.status.success() {
            return Err(RunError::rev_list(
                CommitId::new(base_ref),
                CommitId::new(new_ref),
                &rev_list.stderr,
            ));
        }
        let refs = String::from_utf8_lossy(&rev_list.stdout);

        Ok(refs.lines().map(CommitId::new).collect())
    }

    /// Run a commit check over a commit.
    fn run_check(ctx: &CheckGitContext, check: &dyn Check, commit: &Commit) -> CheckResult {
        debug!(
            target: "git-checks",
            "running check {} on commit {}",
            check.name(),
            commit.sha1,
        );

        check.check(ctx, commit).unwrap_or_else(|err| {
            error!(
                target: "git-checks",
                "check {} failed on commit {}: {:?}",
                check.name(),
                commit.sha1,
                err,
            );

            let mut res = CheckResult::new();
            res.add_alert(
                format!(
                    "failed to run the {} check on commit {}",
                    check.name(),
                    commit.sha1,
                ),
                true,
            );
            res
        })
    }

    /// Run a branch check over a commit.
    fn run_branch_check(
        ctx: &CheckGitContext,
        check: &dyn BranchCheck,
        commit: &CommitId,
    ) -> CheckResult {
        debug!(target: "git-checks", "running check {}", check.name());

        check.check(ctx, commit).unwrap_or_else(|err| {
            error!(
                target: "git-checks",
                "branch check {}: {:?}",
                check.name(),
                err,
            );

            let mut res = CheckResult::new();
            res.add_alert(
                format!("failed to run the {} branch check", check.name()),
                true,
            );
            res
        })
    }

    /// Run a topic check over a topic.
    fn run_topic_check(
        ctx: &CheckGitContext,
        check: &dyn TopicCheck,
        topic: &Topic,
    ) -> CheckResult {
        debug!(target: "git-checks", "running check {}", check.name());

        check.check(ctx, topic).unwrap_or_else(|err| {
            error!(
                target: "git-checks",
                "topic check {}: {:?}",
                check.name(),
                err,
            );

            let mut res = CheckResult::new();
            res.add_alert(
                format!("failed to run the {} topic check", check.name()),
                true,
            );
            res
        })
    }

    /// Run checks over a given topic and collect results from the checks.
    fn run_topic_impl(
        &self,
        ctx: &GitContext,
        base: &CommitId,
        refs: Vec<CommitId>,
        owner: &Identity,
    ) -> Result<TopicCheckResult, RunError> {
        let topic_result = refs.last().map_or_else(
            || Ok(CheckResult::new()) as Result<_, RunError>,
            |head_commit| {
                // Avoid setting up the workarea if there aren't any branch or topic checks.
                if self.checks_branch.is_empty() && self.checks_topic.is_empty() {
                    return Ok(CheckResult::new());
                }

                let workarea = ctx.prepare(head_commit)?;
                let check_ctx = CheckGitContext::new(workarea, owner.clone());
                let topic = Topic::new(ctx, base, head_commit)?;

                Ok(self
                    .checks_branch
                    .par_iter()
                    .map(|&check| Self::run_branch_check(&check_ctx, check, head_commit))
                    .chain(
                        self.checks_topic
                            .par_iter()
                            .map(|&check| Self::run_topic_check(&check_ctx, check, &topic)),
                    )
                    .reduce(CheckResult::new, CheckResult::combine))
            },
        )?;
        let commit_results = refs
            .into_par_iter()
            .map(|sha1| {
                self.run_commit(ctx, &sha1, owner)
                    .map(|result| (sha1, result))
            })
            .collect::<Vec<Result<_, RunError>>>()
            .into_iter()
            .collect::<Result<Vec<_>, RunError>>()?;

        Ok(TopicCheckResult {
            commit_results,
            topic_result,
        })
    }

    /// Run checks over a given commit.
    pub fn run_commit(
        &self,
        ctx: &GitContext,
        commit: &CommitId,
        owner: &Identity,
    ) -> Result<CheckResult, RunError> {
        // Avoid setting up the workarea if there aren't any per-commit checks.
        if self.checks.is_empty() {
            return Ok(CheckResult::new());
        }

        let workarea = ctx.prepare(commit)?;
        let check_ctx = CheckGitContext::new(workarea, owner.clone());

        let commit = Commit::new(ctx, commit)?;

        Ok(self
            .checks
            .par_iter()
            .map(|&check| Self::run_check(&check_ctx, check, &commit))
            .reduce(CheckResult::new, CheckResult::combine))
    }

    /// Run checks over a given topic and collect results from the checks.
    pub fn run_topic<R>(
        &self,
        ctx: &GitContext,
        reason: R,
        base_branch: &CommitId,
        topic: &CommitId,
        owner: &Identity,
    ) -> Result<TopicCheckResult, RunError>
    where
        R: AsRef<str>,
    {
        let refs = self.list(ctx, reason.as_ref(), base_branch, topic)?;
        self.run_topic_impl(ctx, base_branch, refs, owner)
    }
}

impl<'a> Debug for GitCheckConfiguration<'a> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "GitCheckConfiguration {{ {} commit checks, {} branch checks }}",
            self.checks.len(),
            self.checks_branch.len(),
        )
    }
}