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

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

/// A check which checks for fast-forward merge statuses.
///
/// Note that this check is fundamentally temporally bound because the state of the target ref can
/// change the state of this check.
///
/// By default, only warnings are produced.
#[derive(Builder, Debug, Clone)]
#[builder(field(private))]
pub struct FastForward {
    /// The branch name of the release being checked for.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    branch: CommitId,
    /// Whether the check should error or just warn.
    ///
    /// Configuration: Optional
    /// Default: `false`
    #[builder(default = "false")]
    required: bool,
}

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

impl BranchCheck for FastForward {
    fn name(&self) -> &str {
        "fast-forward"
    }

    fn check(
        &self,
        ctx: &CheckGitContext,
        commit: &CommitId,
    ) -> Result<CheckResult, Box<dyn Error>> {
        let merge_base = ctx
            .git()
            .arg("merge-base")
            .arg("--is-ancestor")
            .arg(self.branch.as_str())
            .arg(commit.as_str())
            .output()
            .map_err(|err| GitError::subcommand("merge-base", err))?;
        let ok = match merge_base.status.code() {
            Some(0) => true,
            Some(1) => false,
            code => {
                return Err(FastForwardError::merge_base(
                    commit.clone(),
                    self.branch.clone(),
                    code,
                    &merge_base.stderr,
                )
                .into());
            },
        };

        let mut result = CheckResult::new();

        // Indicate that the branch is eligible for fast-forward merges.
        if !ok {
            if self.required {
                result.add_error(format!(
                    "This branch is ineligible for the fast-forward merging into the `{}` branch; \
                     it needs to be rebased.",
                    self.branch,
                ));
            } else {
                result.add_warning(format!(
                    "Not eligible for fast-forward merging into `{}`.",
                    self.branch,
                ));
            }
        }

        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::FastForward;

    /// Configuration for the `FastForward` check.
    ///
    /// The `branch` key is a string which is the name of the branch which is the target of
    /// merging. 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 `"fast_forward"`.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///     "branch": "master",
    ///     "required": true
    /// }
    /// ```
    #[derive(Deserialize, Debug)]
    pub struct FastForwardConfig {
        branch: String,
        #[serde(default)]
        required: Option<bool>,
    }

    impl IntoCheck for FastForwardConfig {
        type Check = FastForward;

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

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

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

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

    register_checks! {
        FastForwardConfig {
            "fast_forward" => BranchCheckConfig,
        },
    }

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

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

    #[test]
    fn test_fast_forward_config_minimum_fields() {
        let exp_branch = CommitId::new("v1.x");
        let json = json!({
            "branch": exp_branch.as_str(),
        });
        let check: FastForwardConfig = serde_json::from_value(json).unwrap();

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

        let check = check.into_check();

        assert_eq!(check.branch, exp_branch);
        assert!(!check.required);
    }

    #[test]
    fn test_fast_forward_config_all_fields() {
        let exp_branch = CommitId::new("v1.x");
        let json = json!({
            "branch": exp_branch.as_str(),
            "required": true,
        });
        let check: FastForwardConfig = serde_json::from_value(json).unwrap();

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

        let check = check.into_check();

        assert_eq!(check.branch, exp_branch);
        assert!(check.required);
    }
}

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

    use crate::builders::FastForwardBuilder;
    use crate::test::*;
    use crate::FastForward;

    const RELEASE_BRANCH: &str = "3a22ca19fda09183da2faab60819ff6807568acd";
    const NON_FF_TOPIC: &str = "a61fd3759b61a4a1f740f3fe656bc42151cefbdd";
    const FF_TOPIC: &str = "969b794ea82ce51d1555852de33bfcb63dfec969";

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

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

    #[test]
    fn test_fast_forward_builder_minimum_fields() {
        assert!(FastForward::builder()
            .branch(CommitId::new("release"))
            .build()
            .is_ok());
    }

    #[test]
    fn test_fast_forward_name_branch() {
        let check = FastForward::builder()
            .branch(CommitId::new("release"))
            .build()
            .unwrap();
        assert_eq!(BranchCheck::name(&check), "fast-forward");
    }

    fn make_fast_forward_check() -> FastForwardBuilder {
        let mut builder = FastForward::builder();
        builder.branch(CommitId::new(RELEASE_BRANCH));
        builder
    }

    #[test]
    fn test_fast_forward_ok() {
        let check = make_fast_forward_check().build().unwrap();
        run_branch_check_ok("test_fast_forward_ok", FF_TOPIC, check);
    }

    #[test]
    fn test_fast_forward_ok_required() {
        let check = make_fast_forward_check().required(true).build().unwrap();
        run_branch_check_ok("test_fast_forward_ok_required", FF_TOPIC, check);
    }

    #[test]
    fn test_fast_forward_bad() {
        let check = make_fast_forward_check().build().unwrap();
        let result = run_branch_check("test_fast_forward_bad", NON_FF_TOPIC, check);
        test_result_warnings(
            result,
            &["Not eligible for fast-forward merging into \
               `3a22ca19fda09183da2faab60819ff6807568acd`."],
        );
    }

    #[test]
    fn test_fast_forward_bad_required() {
        let check = make_fast_forward_check().required(true).build().unwrap();
        let result = run_branch_check("test_fast_forward_bad_required", NON_FF_TOPIC, check);
        test_result_errors(
            result,
            &[
                "This branch is ineligible for the fast-forward merging into the \
                 `3a22ca19fda09183da2faab60819ff6807568acd` branch; it needs to be rebased.",
            ],
        );
    }
}