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 ReleaseBranchError {
    #[error(
        "failed to get the merge-base for {} against a release branch {}: {}",
        commit,
        base,
        output
    )]
    MergeBase {
        commit: CommitId,
        base: CommitId,
        output: String,
    },
}

impl ReleaseBranchError {
    fn merge_base(commit: CommitId, base: CommitId, output: &[u8]) -> Self {
        ReleaseBranchError::MergeBase {
            commit,
            base,
            output: String::from_utf8_lossy(output).into(),
        }
    }
}

/// A check which checks for release branch eligibility.
///
/// By default, only warnings are produced.
#[derive(Builder, Debug, Clone)]
#[builder(field(private))]
pub struct ReleaseBranch {
    /// The branch name of the release being checked for.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    branch: String,
    /// The first commit on the target branch not allowed on the release branch.
    ///
    /// This is usually the first commit on the main integration branch after the release branch
    /// forked from it.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    disallowed_commit: CommitId,
    /// Whether the check should error or just warn.
    ///
    /// Configuration: Optional
    /// Default: `false`
    #[builder(default = "false")]
    required: bool,
}

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

impl BranchCheck for ReleaseBranch {
    fn name(&self) -> &str {
        "release-branch"
    }

    fn check(
        &self,
        ctx: &CheckGitContext,
        commit: &CommitId,
    ) -> Result<CheckResult, Box<dyn Error>> {
        let merge_base = ctx
            .git()
            .arg("merge-base")
            .arg("--all")
            .arg(commit.as_str())
            .arg(self.disallowed_commit.as_str())
            .output()
            .map_err(|err| GitError::subcommand("merge-base", err))?;
        if !merge_base.status.success() {
            return Err(ReleaseBranchError::merge_base(
                commit.clone(),
                self.disallowed_commit.clone(),
                &merge_base.stderr,
            )
            .into());
        }
        let merge_bases = String::from_utf8_lossy(&merge_base.stdout);
        let is_eligible = merge_bases
            .lines()
            .all(|merge_base| merge_base != self.disallowed_commit.as_str());

        let mut result = CheckResult::new();

        // Indicate that the branch is eligible (if it is required, say nothing).
        if is_eligible && !self.required {
            result.add_warning(format!("Eligible for the {} branch.", self.branch));
        // Error out if the branch is not eligible, but is required.
        } else if !is_eligible && self.required {
            result.add_error(format!(
                "This branch is ineligible for the {} branch; it needs to \
                 be based on a commit before {}.",
                self.branch, self.disallowed_commit,
            ));
        }

        Ok(result)
    }
}

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

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

    /// Configuration for the `ReleaseBranch` check.
    ///
    /// The `branch` key is a string which defaults to `release`. This is the name of the branch
    /// which contains the version which is being checked. The `disallowed_commit` is a string with
    /// the full hash of the first commit which happened on the original branch after the release
    /// branch was created. The `required` key is a boolean defaulting to `false` which indicates
    /// whether the check is a hard failure or not.
    ///
    /// This check is registered as a branch check with the name `"branch_check"`.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///     "branch": "v1.x",
    ///     "disallowed_commit": "post-branch commit hash",
    ///     "required": true
    /// }
    /// ```
    #[derive(Deserialize, Debug)]
    pub struct ReleaseBranchConfig {
        branch: String,
        disallowed_commit: String,
        #[serde(default)]
        required: Option<bool>,
    }

    impl IntoCheck for ReleaseBranchConfig {
        type Check = ReleaseBranch;

        fn into_check(self) -> Self::Check {
            let mut builder = ReleaseBranch::builder();

            builder
                .branch(self.branch)
                .disallowed_commit(CommitId::new(self.disallowed_commit));

            if let Some(required) = self.required {
                builder.required(required);
            }

            builder
                .build()
                .expect("configuration mismatch for `ReleaseBranch`")
        }
    }

    register_checks! {
        ReleaseBranchConfig {
            "release_branch" => BranchCheckConfig,
        },
    }

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

    #[test]
    fn test_release_branch_config_branch_is_required() {
        let exp_disallowed_commit = "post-branch commit hash";
        let json = json!({
            "disallowed_commit": exp_disallowed_commit,
        });
        let err = serde_json::from_value::<ReleaseBranchConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "branch");
    }

    #[test]
    fn test_release_branch_config_disallowed_commit_is_required() {
        let exp_branch = "v1.x";
        let json = json!({
            "branch": exp_branch,
        });
        let err = serde_json::from_value::<ReleaseBranchConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "disallowed_commit");
    }

    #[test]
    fn test_release_branch_config_minimum_fields() {
        let exp_branch = "v1.x";
        let exp_disallowed_commit = "post-branch commit hash";
        let json = json!({
            "branch": exp_branch,
            "disallowed_commit": exp_disallowed_commit,
        });
        let check: ReleaseBranchConfig = serde_json::from_value(json).unwrap();

        assert_eq!(check.branch, exp_branch);
        assert_eq!(check.disallowed_commit, exp_disallowed_commit);
        assert_eq!(check.required, None);

        let check = check.into_check();

        assert_eq!(check.branch, exp_branch);
        assert_eq!(
            check.disallowed_commit,
            CommitId::new(exp_disallowed_commit),
        );
        assert!(!check.required);
    }

    #[test]
    fn test_release_branch_config_all_fields() {
        let exp_branch = "v1.x";
        let exp_disallowed_commit = "post-branch commit hash";
        let json = json!({
            "branch": exp_branch,
            "disallowed_commit": exp_disallowed_commit,
            "required": true,
        });
        let check: ReleaseBranchConfig = serde_json::from_value(json).unwrap();

        assert_eq!(check.branch, exp_branch);
        assert_eq!(check.disallowed_commit, exp_disallowed_commit);
        assert_eq!(check.required, Some(true));

        let check = check.into_check();

        assert_eq!(check.branch, exp_branch);
        assert_eq!(
            check.disallowed_commit,
            CommitId::new(exp_disallowed_commit),
        );
        assert!(check.required);
    }
}

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

    use crate::builders::ReleaseBranchBuilder;
    use crate::test::*;
    use crate::ReleaseBranch;

    const RELEASE_BRANCH: &str = "3a22ca19fda09183da2faab60819ff6807568acd";
    const POST_RELEASE_COMMIT: &str = "d02f015907371738253a22b9a7fec78607a969b2";
    const POST_RELEASE_BRANCH: &str = "a61fd3759b61a4a1f740f3fe656bc42151cefbdd";
    const POST_RELEASE_BRANCH_MERGE: &str = "c58dd19d9976722d82aa6bc6a52a2a01a52bd9e8";

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

    #[test]
    fn test_release_branch_builder_branch_is_required() {
        assert!(ReleaseBranch::builder()
            .disallowed_commit(CommitId::new(POST_RELEASE_COMMIT))
            .build()
            .is_err());
    }

    #[test]
    fn test_release_branch_builder_commit_is_required() {
        assert!(ReleaseBranch::builder().branch("release").build().is_err());
    }

    #[test]
    fn test_release_branch_builder_minimum_fields() {
        assert!(ReleaseBranch::builder()
            .branch("release")
            .disallowed_commit(CommitId::new(POST_RELEASE_COMMIT))
            .build()
            .is_ok());
    }

    #[test]
    fn test_release_branch_name_branch() {
        let check = ReleaseBranch::builder()
            .branch("release")
            .disallowed_commit(CommitId::new(POST_RELEASE_COMMIT))
            .build()
            .unwrap();
        assert_eq!(BranchCheck::name(&check), "release-branch");
    }

    fn make_release_branch_check() -> ReleaseBranchBuilder {
        let mut builder = ReleaseBranch::builder();
        builder
            .branch("release")
            .disallowed_commit(CommitId::new(POST_RELEASE_COMMIT));
        builder
    }

    #[test]
    fn test_release_branch_ok() {
        let check = make_release_branch_check().build().unwrap();
        let result = run_branch_check("test_release_branch_ok", RELEASE_BRANCH, check);
        test_result_warnings(result, &["Eligible for the release branch."]);
    }

    #[test]
    fn test_release_branch_ok_required() {
        let check = make_release_branch_check().required(true).build().unwrap();
        run_branch_check_ok("test_release_branch_ok_required", RELEASE_BRANCH, check);
    }

    #[test]
    fn test_post_release_branch() {
        let check = make_release_branch_check().build().unwrap();
        run_branch_check_ok("test_post_release_branch", POST_RELEASE_BRANCH, check);
    }

    #[test]
    fn test_post_release_branch_required() {
        let check = make_release_branch_check().required(true).build().unwrap();
        let result = run_branch_check(
            "test_post_release_branch_required",
            POST_RELEASE_BRANCH,
            check,
        );
        test_result_errors(result, &[
            "This branch is ineligible for the release branch; it needs to be based on a commit \
             before d02f015907371738253a22b9a7fec78607a969b2.",
        ]);
    }

    #[test]
    fn test_post_release_branch_merge() {
        let check = make_release_branch_check().build().unwrap();
        run_branch_check_ok(
            "test_post_release_branch_merge",
            POST_RELEASE_BRANCH_MERGE,
            check,
        );
    }

    #[test]
    fn test_post_release_branch_merge_required() {
        let check = make_release_branch_check().required(true).build().unwrap();
        let result = run_branch_check(
            "test_post_release_branch_merge_required",
            POST_RELEASE_BRANCH_MERGE,
            check,
        );
        test_result_errors(result, &[
            "This branch is ineligible for the release branch; it needs to be based on a commit \
             before d02f015907371738253a22b9a7fec78607a969b2.",
        ]);
    }
}