git-checks 4.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 crates::git_checks_core::impl_prelude::*;
use crates::thiserror::Error;

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

impl BadCommitError {
    fn rev_list(commit: CommitId, base: CommitId, output: &[u8]) -> Self {
        BadCommitError::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 BadCommit {
    /// The commit to deny.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    commit: CommitId,
    /// The reason the commit is bad.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    reason: String,
}

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

    fn apply(&self, result: &mut CheckResult) {
        result
            .add_error(format!(
                "commit {} is not allowed {}.",
                self.commit, self.reason,
            ))
            .add_alert(
                format!("commit {} was pushed to the server.", self.commit),
                true,
            );
    }
}

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

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

        if self.commit == commit.sha1 {
            self.apply(&mut result);
        }

        Ok(result)
    }
}

impl TopicCheck for BadCommit {
    fn name(&self) -> &str {
        "bad-commit-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(BadCommitError::rev_list(
                topic.sha1.clone(),
                topic.base.clone(),
                &rev_list.stderr,
            )
            .into());
        }

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

        let mut result = CheckResult::new();
        if refs.lines().any(|rev| rev == self.commit.as_str()) {
            self.apply(&mut result)
        }
        Ok(result)
    }
}

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

    #[cfg(test)]
    use test;
    use BadCommit;

    /// Configuration for the `BadCommit` check.
    ///
    /// The `commit` and `reason` fields are required and are both strings. The commit must be a
    /// full commit hash.
    ///
    /// This check is registered as a commit check with the name `"bad_commit"` and as a topic
    /// check with the name `"bad_commit/topic"`.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///     "commit": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
    ///     "reason": "it's a bad commit"
    /// }
    /// ```
    #[derive(Deserialize, Debug)]
    pub struct BadCommitConfig {
        commit: String,
        reason: String,
    }

    impl IntoCheck for BadCommitConfig {
        type Check = BadCommit;

        fn into_check(self) -> Self::Check {
            let mut builder = BadCommit::builder();
            builder.commit(CommitId::new(self.commit));
            builder.reason(self.reason);
            builder
                .build()
                .expect("configuration mismatch for `BadCommit`")
        }
    }

    register_checks! {
        BadCommitConfig {
            "bad_commit" => CommitCheckConfig,
            "bad_commit/topic" => TopicCheckConfig,
        },
    }

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

    #[test]
    fn test_bad_commit_config_commit_is_required() {
        let reason = "because";
        let json = json!({
            "reason": reason,
        });
        let err = serde_json::from_value::<BadCommitConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "commit");
    }

    #[test]
    fn test_bad_commit_config_reason_is_required() {
        let commit = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
        let json = json!({
            "commit": commit,
        });
        let err = serde_json::from_value::<BadCommitConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "reason");
    }

    #[test]
    fn test_bad_commit_config_minimum_fields() {
        let commit = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
        let reason = "because";
        let json = json!({
            "commit": commit,
            "reason": reason,
        });
        let check: BadCommitConfig = serde_json::from_value(json).unwrap();

        assert_eq!(check.commit, commit);
        assert_eq!(check.reason, reason);
    }
}

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

    use test::*;
    use BadCommit;

    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_commit_builder_default() {
        assert!(BadCommit::builder().build().is_err());
    }

    #[test]
    fn test_bad_commit_builder_reason_is_required() {
        assert!(BadCommit::builder()
            .commit(CommitId::new(NO_EXIST_COMMIT))
            .build()
            .is_err());
    }

    #[test]
    fn test_bad_commit_builder_commit_is_required() {
        assert!(BadCommit::builder().reason("because").build().is_err());
    }

    #[test]
    fn test_bad_commit_builder_minimum_fields() {
        assert!(BadCommit::builder()
            .commit(CommitId::new(NO_EXIST_COMMIT))
            .reason("because")
            .build()
            .is_ok());
    }

    #[test]
    fn test_bad_commit_good_commit() {
        let check = BadCommit::builder()
            .commit(CommitId::new(BAD_COMMIT))
            .reason("because")
            .build()
            .unwrap();
        run_check_ok("test_bad_commit_good_commit", GOOD_COMMIT, check);
    }

    #[test]
    fn test_bad_commit_no_bad_commit() {
        let check = BadCommit::builder()
            // This commit should never exist.
            .commit(CommitId::new(NO_EXIST_COMMIT))
            .reason("because")
            .build()
            .unwrap();
        run_check_ok("test_bad_commit_no_bad_commit", BAD_TOPIC, check);
    }

    #[test]
    fn test_bad_commit_already_in_history() {
        let check = BadCommit::builder()
            // This commit is in the shared history.
            .commit(CommitId::new(FILLER_COMMIT))
            .reason("because")
            .build()
            .unwrap();
        run_check_ok("test_bad_commit_already_in_history", BAD_TOPIC, check);
    }

    #[test]
    fn test_bad_commit_not_already_in_history() {
        let check = BadCommit::builder()
            // This commit is on the branch being brought in.
            .commit(CommitId::new(BAD_COMMIT))
            .reason("because")
            .build()
            .unwrap();
        let result = run_check("test_bad_commit_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 not allowed because.",
        );
        assert_eq!(result.temporary(), false);
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), false);
    }

    #[test]
    fn test_bad_commit_topic_good_commit() {
        let check = BadCommit::builder()
            .commit(CommitId::new(BAD_COMMIT))
            .reason("because")
            .build()
            .unwrap();
        run_topic_check_ok("test_bad_commit_topic_good_commit", GOOD_COMMIT, check);
    }

    #[test]
    fn test_bad_commit_topic_no_bad_commit() {
        let check = BadCommit::builder()
            // This commit should never exist.
            .commit(CommitId::new(NO_EXIST_COMMIT))
            .reason("because")
            .build()
            .unwrap();
        run_topic_check_ok("test_bad_commit_topic_no_bad_commit", BAD_TOPIC, check);
    }

    #[test]
    fn test_bad_commit_topic_already_in_history() {
        let check = BadCommit::builder()
            // This commit is in the shared history.
            .commit(CommitId::new(FILLER_COMMIT))
            .reason("because")
            .build()
            .unwrap();
        run_topic_check_ok("test_bad_commit_topic_already_in_history", BAD_TOPIC, check);
    }

    #[test]
    fn test_bad_commit_topic_not_already_in_history() {
        let check = BadCommit::builder()
            // This commit is on the topic being brought in.
            .commit(CommitId::new(BAD_COMMIT))
            .reason("because")
            .build()
            .unwrap();
        let result = run_topic_check(
            "test_bad_commit_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 not allowed because.",
        );
        assert_eq!(result.temporary(), false);
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), false);
    }
}