git-checks 4.2.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 derive_builder::Builder;
use git_checks_core::impl_prelude::*;
use thiserror::Error;

#[derive(Debug, Error)]
enum BadCommitsError {
    #[error("failed to list topic refs from {} to {}: {}", base, commit, output)]
    RevList {
        commit: CommitId,
        base: CommitId,
        output: String,
    },
}

impl BadCommitsError {
    fn rev_list(commit: CommitId, base: CommitId, output: &[u8]) -> Self {
        BadCommitsError::RevList {
            commit,
            base,
            output: String::from_utf8_lossy(output).into(),
        }
    }
}

/// Check for commits which should not be in the history.
#[derive(Builder, Debug, Clone)]
#[builder(field(private))]
pub struct BadCommits {
    #[builder(private)]
    #[builder(setter(name = "_bad_commits"))]
    bad_commits: Vec<CommitId>,
}

impl BadCommitsBuilder {
    /// The set of bad commits to deny.
    ///
    /// Full commit hashes should be used. These are not passed through `git rev-parse`.
    ///
    /// Configuration: Required
    pub fn bad_commits<I>(&mut self, bad_commits: I) -> &mut Self
    where
        I: IntoIterator,
        I::Item: Into<CommitId>,
    {
        self.bad_commits = Some(bad_commits.into_iter().map(Into::into).collect());
        self
    }
}

impl BadCommits {
    /// Create a new builder.
    pub fn builder() -> BadCommitsBuilder {
        BadCommitsBuilder::default()
    }
}

impl Check for BadCommits {
    fn name(&self) -> &str {
        "bad-commits"
    }

    fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
        let mut result = CheckResult::new();

        if self.bad_commits.contains(&commit.sha1) {
            result
                .add_error(format!(
                    "commit {} is a known-bad commit that was removed from the server.",
                    commit.sha1,
                ))
                .add_alert(
                    format!("commit {} was pushed to the server.", commit.sha1),
                    true,
                );
        }

        Ok(result)
    }
}

impl TopicCheck for BadCommits {
    fn name(&self) -> &str {
        "bad-commits-topic"
    }

    fn check(&self, ctx: &CheckGitContext, topic: &Topic) -> Result<CheckResult, Box<dyn Error>> {
        let rev_list = ctx
            .git()
            .arg("rev-list")
            .arg("--reverse")
            .arg("--topo-order")
            .arg(&topic.sha1.as_str())
            .arg(&format!("^{}", topic.base))
            .output()
            .map_err(|err| GitError::subcommand("rev-list", err))?;
        if !rev_list.status.success() {
            return Err(BadCommitsError::rev_list(
                topic.sha1.clone(),
                topic.base.clone(),
                &rev_list.stderr,
            )
            .into());
        }

        let refs = String::from_utf8_lossy(&rev_list.stdout);

        Ok(refs
            .lines()
            .map(CommitId::new)
            .fold(CheckResult::new(), |mut result, commit| {
                if self.bad_commits.contains(&commit) {
                    result
                        .add_error(format!(
                            "commit {} is a known-bad commit that was removed from the server.",
                            commit,
                        ))
                        .add_alert(format!("commit {} was pushed to the server.", commit), true);
                }

                result
            }))
    }
}

#[cfg(feature = "config")]
pub(crate) mod config {
    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
    use git_workarea::CommitId;
    use serde::Deserialize;
    #[cfg(test)]
    use serde_json::json;

    #[cfg(test)]
    use crate::test;
    use crate::BadCommits;

    /// Configuration for the `BadCommits` check.
    ///
    /// The `bad_commits` field is required and is a list of strings. Full hashes must be used.
    ///
    /// This check is registered as a commit check with the name `"bad_commits"` and as a topic
    /// check with the name `"bad_commits/topic"`. It is recommended to use the topic variant due
    /// to its better performance.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///     "bad_commits": [
    ///         "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
    ///         "abadcafeabadcafeabadcafeabadcafeabadcafeabadcafe"
    ///     ]
    /// }
    /// ```
    #[derive(Deserialize, Debug)]
    pub struct BadCommitsConfig {
        bad_commits: Vec<String>,
    }

    impl IntoCheck for BadCommitsConfig {
        type Check = BadCommits;

        fn into_check(self) -> Self::Check {
            BadCommits::builder()
                .bad_commits(self.bad_commits.into_iter().map(CommitId::new))
                .build()
                .expect("configuration mismatch for `BadCommits`")
        }
    }

    register_checks! {
        BadCommitsConfig {
            "bad_commits" => CommitCheckConfig,
            "bad_commits/topic" => TopicCheckConfig,
        },
    }

    #[test]
    fn test_bad_commits_config_empty() {
        let json = json!({});
        let err = serde_json::from_value::<BadCommitsConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "bad_commits");
    }

    #[test]
    fn test_bad_commits_config_minimum_fields() {
        let commit1: String = "commit hash 1".into();
        let json = json!({
            "bad_commits": [commit1],
        });
        let check: BadCommitsConfig = serde_json::from_value(json).unwrap();

        itertools::assert_equal(&check.bad_commits, &[commit1.clone()]);

        let check = check.into_check();

        itertools::assert_equal(&check.bad_commits, &[CommitId::new(commit1)]);
    }
}

#[cfg(test)]
mod tests {
    use git_checks_core::TopicCheck;
    use git_workarea::CommitId;

    use crate::test::*;
    use crate::BadCommits;

    const NO_EXIST_COMMIT: &str = "0000000000000000000000000000000000000000";
    const GOOD_COMMIT: &str = "7b0c51ed98a23a32718ed7014d6d4a813423f1bd";
    const BAD_COMMIT: &str = "029a00428913ee915ce5ee7250c023abfbc2aca3";
    const BAD_TOPIC: &str = "3d535904b40868dcba6465cf2c3ce4358501880a";

    #[test]
    fn test_bad_commits_builder_default() {
        assert!(BadCommits::builder().build().is_err());
    }

    #[test]
    fn test_bad_commits_builder_minimum_fields() {
        assert!(BadCommits::builder()
            .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .is_ok());
    }

    #[test]
    fn test_bad_commits_name_topic() {
        let check = BadCommits::builder()
            .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        assert_eq!(TopicCheck::name(&check), "bad-commits-topic");
    }

    #[test]
    fn test_bad_commits_good_commit() {
        let check = BadCommits::builder()
            .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        run_check_ok("test_bad_commits_good_commit", GOOD_COMMIT, check);
    }

    #[test]
    fn test_bad_commits_no_bad_commit() {
        let check = BadCommits::builder()
            // This commit should never exist.
            .bad_commits([NO_EXIST_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        run_check_ok("test_bad_commits_no_bad_commit", BAD_TOPIC, check);
    }

    #[test]
    fn test_bad_commits_already_in_history() {
        let check = BadCommits::builder()
            // This commit is in the shared history.
            .bad_commits([FILLER_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        run_check_ok("test_bad_commits_already_in_history", BAD_TOPIC, check);
    }

    #[test]
    fn test_bad_commits_not_already_in_history() {
        let check = BadCommits::builder()
            // This commit is on the branch being brought in.
            .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        let result = run_check("test_bad_commits_not_already_in_history", BAD_TOPIC, check);

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 1);
        assert_eq!(
            result.alerts()[0],
            "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
        );
        assert_eq!(result.errors().len(), 1);
        assert_eq!(
            result.errors()[0],
            "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is a known-bad commit that was \
             removed from the server.",
        );
        assert!(!result.temporary());
        assert!(!result.allowed());
        assert!(!result.pass());
    }

    #[test]
    fn test_bad_commits_topic_good_commit() {
        let check = BadCommits::builder()
            .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        run_topic_check_ok("test_bad_commits_topic_good_commit", GOOD_COMMIT, check);
    }

    #[test]
    fn test_bad_commits_topic_no_bad_commit() {
        let check = BadCommits::builder()
            // This commit should never exist.
            .bad_commits([NO_EXIST_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        run_topic_check_ok("test_bad_commits_topic_no_bad_commit", BAD_TOPIC, check);
    }

    #[test]
    fn test_bad_commits_topic_already_in_history() {
        let check = BadCommits::builder()
            // This commit is in the shared history.
            .bad_commits([FILLER_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        run_topic_check_ok(
            "test_bad_commits_topic_already_in_history",
            BAD_TOPIC,
            check,
        );
    }

    #[test]
    fn test_bad_commits_topic_not_already_in_history() {
        let check = BadCommits::builder()
            // This commit is on the topic being brought in.
            .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
            .build()
            .unwrap();
        let result = run_topic_check(
            "test_bad_commits_topic_not_already_in_history",
            BAD_TOPIC,
            check,
        );

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 1);
        assert_eq!(
            result.alerts()[0],
            "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
        );
        assert_eq!(result.errors().len(), 1);
        assert_eq!(
            result.errors()[0],
            "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is a known-bad commit that was \
             removed from the server.",
        );
        assert!(!result.temporary());
        assert!(!result.allowed());
        assert!(!result.pass());
    }
}