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 std::collections::BTreeSet;

use derive_builder::Builder;
use git_checks_core::impl_prelude::*;
use thiserror::Error;

#[derive(Debug, Error)]
enum ThirdPartyError {
    #[error(
        "failed to list revisions to find the root commit of {} for {}: {}",
        commit,
        import,
        output
    )]
    FindRoot {
        import: String,
        commit: CommitId,
        output: String,
    },
    #[error(
        "failed to get the tree object for {} (expected) for {}: {}",
        commit,
        import,
        output
    )]
    ExpectedTreeObject {
        import: String,
        commit: CommitId,
        output: String,
    },
    #[error(
        "failed to get the tree object for {} (actual) for {}: {}",
        commit,
        import,
        output
    )]
    ActualTreeObject {
        import: String,
        commit: CommitId,
        output: String,
    },
    #[error("unexpected output from `git ls-tree`: {}", output)]
    LsTreeOutput { output: String },
}

impl ThirdPartyError {
    fn find_root(import: String, commit: CommitId, output: &[u8]) -> Self {
        ThirdPartyError::FindRoot {
            import,
            commit,
            output: String::from_utf8_lossy(output).into(),
        }
    }

    fn expected_tree_object(import: String, commit: CommitId, output: &[u8]) -> Self {
        ThirdPartyError::ExpectedTreeObject {
            import,
            commit,
            output: String::from_utf8_lossy(output).into(),
        }
    }

    fn actual_tree_object(import: String, commit: CommitId, output: &[u8]) -> Self {
        ThirdPartyError::ActualTreeObject {
            import,
            commit,
            output: String::from_utf8_lossy(output).into(),
        }
    }

    fn ls_tree_output(output: String) -> Self {
        ThirdPartyError::LsTreeOutput {
            output,
        }
    }
}

/// Description of a third party package imported using Kitware's third party import process.
///
/// The workflow used at Kitware for third party packages is to keep all changes tracked in
/// separate repositories. This makes tracking patches to the projects easier to manage and extract
/// for submission to the appropriate upstream project.
///
/// When a project is imported, it uses a separate history which contains only snapshots of the
/// tracked repository. When imported into a project, it can select a subset of files to keep, drop
/// extra metadata into the import, or perform other transformations as necessary. Whatever the
/// result of that is, it is added as a new commit on the history of the tracking branch for the
/// project. This is then merged into the main project using a subtree strategy to move the project
/// to the correct place.
///
/// This check checks to make sure that any modifications in the main project's imported location of
/// the third party project are made on the tracking branch.
#[derive(Builder, Debug, Clone)]
#[builder(field(private))]
pub struct ThirdParty {
    /// The name of the imported project.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    pub name: String,
    /// The path the third party project lives once merged.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    pub path: String,
    /// The root commit of the third party tracking branch.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    pub root: String,
    /// The location of the utility to use for importing this project.
    ///
    /// Configuration: Required
    #[builder(setter(into))]
    pub utility: String,
}

impl ThirdParty {
    /// Create a new third party import configuration.
    pub fn builder() -> ThirdPartyBuilder {
        ThirdPartyBuilder::default()
    }
}

/// Whether a commit is on the import branch or not.
enum CheckRefResult {
    /// Indicates the commit is on the import branch.
    IsImport,
    /// Indicates the commit is not part of the import branch.
    ///
    /// Stores its error message.
    Rejected(String),
}

impl CheckRefResult {
    /// Whether the result is an import or not.
    #[allow(clippy::match_like_matches_macro)]
    fn is_import(&self) -> bool {
        if let CheckRefResult::IsImport = *self {
            true
        } else {
            false
        }
    }

    /// Add the import result to a check result.
    fn add_result(self, result: &mut CheckResult) {
        if let CheckRefResult::Rejected(err) = self {
            result.add_error(err);
        }
    }
}

impl Check for ThirdParty {
    fn name(&self) -> &str {
        "third-party"
    }

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

        // A flag to indicate that we need to check the commit for an evil merge.
        let mut check_tree = false;
        let mut names_checked = BTreeSet::new();

        for diff in &commit.diffs {
            let name_path = diff.name.as_path();

            if !name_path.starts_with(&self.path) {
                continue;
            }

            // Avoid checking the same path multiple times. This can occur when an evil merge and
            // at least one of its parents modify the same path.
            if !names_checked.insert(diff.name.as_bytes()) {
                continue;
            }

            let check_ref = |sha1: &CommitId| -> Result<_, Box<dyn Error>> {
                let rev_list = ctx
                    .git()
                    .arg("rev-list")
                    .arg("--first-parent")
                    .arg("--max-parents=0")
                    .arg(sha1.as_str())
                    .output()
                    .map_err(|err| GitError::subcommand("rev-list", err))?;
                if !rev_list.status.success() {
                    return Err(ThirdPartyError::find_root(
                        self.name.clone(),
                        sha1.clone(),
                        &rev_list.stderr,
                    )
                    .into());
                }
                let refs = String::from_utf8_lossy(&rev_list.stdout);
                let is_import = self.root == refs.trim();

                Ok(if is_import {
                    CheckRefResult::IsImport
                } else {
                    let msg = format!(
                        "commit {} not allowed; the `{}` file is maintained by the third party \
                         utilities; please use `{}` to update this file.",
                        commit.sha1, diff.name, self.utility,
                    );

                    CheckRefResult::Rejected(msg)
                })
            };

            if commit.parents.len() == 2 {
                let is_import = check_ref(&commit.parents[1])?.is_import();

                if is_import {
                    // Check that the merge commit is not "evil" for the import directory.
                    check_tree = true;
                } else {
                    check_ref(&commit.sha1)?.add_result(&mut result);
                }
            } else {
                check_ref(&commit.sha1)?.add_result(&mut result);
            }
        }

        if check_tree {
            // Get the tree of the import branch.
            let rev_parse = ctx
                .git()
                .arg("rev-parse")
                .arg(format!("{}^{{tree}}", commit.parents[1]))
                .output()
                .map_err(|err| GitError::subcommand("rev-parse", err))?;
            if !rev_parse.status.success() {
                return Err(ThirdPartyError::expected_tree_object(
                    self.name.clone(),
                    commit.parents[1].clone(),
                    &rev_parse.stderr,
                )
                .into());
            }
            let expected_tree = String::from_utf8_lossy(&rev_parse.stdout);

            // Get the tree of the imported tree in the merge commit.
            let ls_tree = ctx
                .git()
                .arg("ls-tree")
                .arg(commit.sha1.as_str())
                .arg(&self.path)
                .output()
                .map_err(|err| GitError::subcommand("ls-tree", err))?;
            if !ls_tree.status.success() {
                return Err(ThirdPartyError::actual_tree_object(
                    self.name.clone(),
                    commit.sha1.clone(),
                    &ls_tree.stderr,
                )
                .into());
            }
            let ls_tree_output = String::from_utf8_lossy(&ls_tree.stdout);
            let actual_tree = ls_tree_output
                .split_whitespace()
                .nth(2)
                .ok_or_else(|| ThirdPartyError::ls_tree_output(ls_tree_output.clone().into()))?;

            // Ensure the trees match.
            if actual_tree != expected_tree.trim() {
                let msg = format!(
                    "commit {} not allowed; the `{}` directory contains changes not on the import \
                     branch; merge conflicts should not happen and indicate that the import \
                     directory was manually edited at some point. Please find and revert the bad \
                     edit, apply it to the imported repository (if necessary), and then run the \
                     import utility.",
                    commit.sha1, self.path,
                );

                result.add_error(msg);
            }
        }

        Ok(result)
    }
}

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

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

    /// Configuration for the `ThirdParty` check.
    ///
    /// All keys are required and strings. The `name` and `script` keys are informational and only
    /// appear in messages. Any modifications at `path` are checked to ensure that they are tracked
    /// on an "import branch" rooted with the given commit specified by the `root` key.
    ///
    /// This check is registered as a commit check with the name `"third_party"`.
    ///
    /// # Example
    ///
    /// ```json
    /// {
    ///     "name": "extlib",
    ///     "path": "path/to/import/of/extlib",
    ///     "root": "root commit",
    ///     "script": "path/to/update/script"
    /// }
    /// ```
    #[derive(Deserialize, Debug)]
    pub struct ThirdPartyConfig {
        name: String,
        path: String,
        root: String,
        utility: String,
    }

    impl IntoCheck for ThirdPartyConfig {
        type Check = ThirdParty;

        fn into_check(self) -> Self::Check {
            ThirdParty::builder()
                .name(self.name)
                .path(self.path)
                .root(self.root)
                .utility(self.utility)
                .build()
                .expect("configuration mismatch for `ThirdParty`")
        }
    }

    register_checks! {
        ThirdPartyConfig {
            "third_party" => CommitCheckConfig,
        },
    }

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

    #[test]
    fn test_third_party_config_name_is_required() {
        let exp_path = "path/to/import/of/extlib";
        let exp_root = "root commit";
        let exp_utility = "path/to/update/utility";
        let json = json!({
            "path": exp_path,
            "root": exp_root,
            "utility": exp_utility,
        });
        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "name");
    }

    #[test]
    fn test_third_party_config_path_is_required() {
        let exp_name = "extlib";
        let exp_root = "root commit";
        let exp_utility = "path/to/update/utility";
        let json = json!({
            "name": exp_name,
            "root": exp_root,
            "utility": exp_utility,
        });
        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "path");
    }

    #[test]
    fn test_third_party_config_root_is_required() {
        let exp_name = "extlib";
        let exp_path = "path/to/import/of/extlib";
        let exp_utility = "path/to/update/utility";
        let json = json!({
            "name": exp_name,
            "path": exp_path,
            "utility": exp_utility,
        });
        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "root");
    }

    #[test]
    fn test_third_party_config_utility_is_required() {
        let exp_name = "extlib";
        let exp_path = "path/to/import/of/extlib";
        let exp_root = "root commit";
        let json = json!({
            "name": exp_name,
            "path": exp_path,
            "root": exp_root,
        });
        let err = serde_json::from_value::<ThirdPartyConfig>(json).unwrap_err();
        test::check_missing_json_field(err, "utility");
    }

    #[test]
    fn test_third_party_config_minimum_fields() {
        let exp_name = "extlib";
        let exp_path = "path/to/import/of/extlib";
        let exp_root = "root commit";
        let exp_utility = "path/to/update/utility";
        let json = json!({
            "name": exp_name,
            "path": exp_path,
            "root": exp_root,
            "utility": exp_utility,
        });
        let check: ThirdPartyConfig = serde_json::from_value(json).unwrap();

        assert_eq!(check.name, exp_name);
        assert_eq!(check.path, exp_path);
        assert_eq!(check.root, exp_root);
        assert_eq!(check.utility, exp_utility);

        let check = check.into_check();

        assert_eq!(check.name, exp_name);
        assert_eq!(check.path, exp_path);
        assert_eq!(check.root, exp_root);
        assert_eq!(check.utility, exp_utility);
    }
}

#[cfg(test)]
mod tests {
    use git_checks_core::Check;

    use crate::test::*;
    use crate::ThirdParty;

    const BASE_COMMIT: &str = "26576e49345a141eca310af92737e489c9baac24";
    const VALID_UPDATE_TOPIC: &str = "0bd161c8187d4f727a7acc17020711dcc139b166";
    const INVALID_UPDATE_TOPIC: &str = "af154fdff05c871125f2db03eccbdde8571d484e";
    const EVIL_UPDATE_TOPIC: &str = "add18e5ab9a67303337cb2754c675fb2e0a45a79";
    const EVIL_UPDATE_TOPIC_AND_PARENT: &str = "1c6a384e064b0fc5a80685216f24dd702bdfa5c7";

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

    #[test]
    fn test_third_party_builder_name_is_required() {
        assert!(ThirdParty::builder()
            .path("check_size")
            .root("d50197ebd7167b0941d34405686164068db0b77b")
            .utility("./update.sh")
            .build()
            .is_err());
    }

    #[test]
    fn test_third_party_builder_path_is_required() {
        assert!(ThirdParty::builder()
            .name("check_size")
            .root("d50197ebd7167b0941d34405686164068db0b77b")
            .utility("./update.sh")
            .build()
            .is_err());
    }

    #[test]
    fn test_third_party_builder_root_is_required() {
        assert!(ThirdParty::builder()
            .name("check_size")
            .path("check_size")
            .utility("./update.sh")
            .build()
            .is_err());
    }

    #[test]
    fn test_third_party_builder_utility_is_required() {
        assert!(ThirdParty::builder()
            .name("check_size")
            .path("check_size")
            .root("d50197ebd7167b0941d34405686164068db0b77b")
            .build()
            .is_err());
    }

    #[test]
    fn test_third_party_builder_minimum_fields() {
        assert!(ThirdParty::builder()
            .name("check_size")
            .path("check_size")
            .root("d50197ebd7167b0941d34405686164068db0b77b")
            .utility("./update.sh")
            .build()
            .is_ok());
    }

    #[test]
    fn test_third_party_name_commit() {
        let check = ThirdParty::builder()
            .name("check_size")
            .path("check_size")
            .root("d50197ebd7167b0941d34405686164068db0b77b")
            .utility("./update.sh")
            .build()
            .unwrap();
        assert_eq!(Check::name(&check), "third-party");
    }

    fn make_third_party_check() -> ThirdParty {
        ThirdParty::builder()
            .name("check_size")
            .path("check_size")
            .root("d50197ebd7167b0941d34405686164068db0b77b")
            .utility("./update.sh")
            .build()
            .unwrap()
    }

    #[test]
    fn test_third_party_valid_update() {
        let check = make_third_party_check();
        let conf = make_check_conf(&check);

        let result = test_check_base(
            "test_third_party_valid_update",
            VALID_UPDATE_TOPIC,
            BASE_COMMIT,
            &conf,
        );
        test_result_ok(result);
    }

    #[test]
    fn test_third_party_invalid_update() {
        let check = make_third_party_check();
        let conf = make_check_conf(&check);

        let result = test_check_base(
            "test_third_party_invalid_update",
            INVALID_UPDATE_TOPIC,
            BASE_COMMIT,
            &conf,
        );
        test_result_errors(result, &[
            "commit af154fdff05c871125f2db03eccbdde8571d484e not allowed; the \
             `check_size/increased-limit` file is maintained by the third party utilities; please \
             use `./update.sh` to update this file.",
        ]);
    }

    #[test]
    fn test_third_party_invalid_update_evil() {
        let check = make_third_party_check();
        let conf = make_check_conf(&check);

        let result = test_check_base(
            "test_third_party_invalid_update_evil",
            EVIL_UPDATE_TOPIC,
            BASE_COMMIT,
            &conf,
        );
        test_result_errors(
            result,
            &[
                "commit add18e5ab9a67303337cb2754c675fb2e0a45a79 not allowed; the `check_size` \
                 directory contains changes not on the import branch; merge conflicts should not \
                 happen and indicate that the import directory was manually edited at some point. \
                 Please find and revert the bad edit, apply it to the imported repository (if \
                 necessary), and then run the import utility.",
            ],
        );
    }

    #[test]
    fn test_third_party_invalid_update_evil_and_parent() {
        let check = make_third_party_check();
        let conf = make_check_conf(&check);

        let result = test_check_base(
            "test_third_party_invalid_update_evil_and_parent",
            EVIL_UPDATE_TOPIC_AND_PARENT,
            BASE_COMMIT,
            &conf,
        );
        test_result_errors(result, &[
            "commit af154fdff05c871125f2db03eccbdde8571d484e not allowed; the \
             `check_size/increased-limit` file is maintained by the third party utilities; please \
             use `./update.sh` to update this file.",
            "commit 1c6a384e064b0fc5a80685216f24dd702bdfa5c7 not allowed; the \
             `check_size/increased-limit` file is maintained by the third party utilities; please \
             use `./update.sh` to update this file.",
        ]);
    }
}