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 std::path::PathBuf;

use crates::git_checks_core::impl_prelude::*;
use crates::thiserror::Error;

#[derive(Debug, Error)]
enum SubmoduleRewindError {
    #[error("failed to get the merge-base between {} (old) and {} (new) in {}: {}", old_commit, new_commit, submodule.display(), output)]
    MergeBase {
        submodule: PathBuf,
        old_commit: CommitId,
        new_commit: CommitId,
        output: String,
    },
}

impl SubmoduleRewindError {
    fn merge_base(
        submodule: &FileName,
        old_commit: CommitId,
        new_commit: CommitId,
        output: &[u8],
    ) -> Self {
        SubmoduleRewindError::MergeBase {
            submodule: submodule.as_path().into(),
            old_commit,
            new_commit,
            output: String::from_utf8_lossy(output).into(),
        }
    }
}

/// Check that submodules are not rewound to older revisions.
#[derive(Builder, Debug, Default, Clone, Copy)]
#[builder(field(private))]
pub struct SubmoduleRewind {}

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

impl Check for SubmoduleRewind {
    fn name(&self) -> &str {
        "submodule-rewind"
    }

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

        for diff in &commit.diffs {
            // Ignore diffs which are not submodules on the new side.
            if diff.new_mode != "160000" {
                continue;
            }

            match diff.status {
                // Ignore submodules which have not been modified.
                StatusChange::Deleted | StatusChange::Added => continue,
                _ => (),
            }

            let submodule_ctx = if let Some(ctx) = SubmoduleContext::new(ctx, diff.name.as_ref()) {
                ctx
            } else {
                continue;
            };

            let cat_file = submodule_ctx
                .context
                .git()
                .arg("cat-file")
                .arg("-t")
                .arg(diff.new_blob.as_str())
                .output()
                .map_err(|err| GitError::subcommand("cat-file -t <new>", err))?;
            let object_type = String::from_utf8_lossy(&cat_file.stdout);
            if !cat_file.status.success() || object_type.trim() != "commit" {
                // The commit is updating to a submodule reference which we can't find; we can't do
                // our work here.
                continue;
            }

            let cat_file = submodule_ctx
                .context
                .git()
                .arg("cat-file")
                .arg("-t")
                .arg(diff.old_blob.as_str())
                .output()
                .map_err(|err| GitError::subcommand("cat-file -t <old>", err))?;
            let object_type = String::from_utf8_lossy(&cat_file.stdout);
            if !cat_file.status.success() || object_type.trim() != "commit" {
                // The commit is updating a submodule reference which we can't find; we can't do
                // our work here.
                continue;
            }

            let merge_base = submodule_ctx
                .context
                .git()
                .arg("merge-base")
                .arg(diff.old_blob.as_str())
                .arg(diff.new_blob.as_str())
                .output()
                .map_err(|err| GitError::subcommand("merge-base", err))?;
            if !merge_base.status.success() {
                return Err(SubmoduleRewindError::merge_base(
                    &diff.name,
                    diff.old_blob.clone(),
                    diff.new_blob.clone(),
                    &merge_base.stderr,
                )
                .into());
            }
            let base = String::from_utf8_lossy(&merge_base.stdout);

            if base.trim() == diff.new_blob.as_str() {
                result.add_error(format!(
                    "commit {} is not allowed since it moves the submodule `{}` backwards from {} \
                     to {}.",
                    commit.sha1, submodule_ctx.path, diff.old_blob, diff.new_blob,
                ));
            }
        }

        Ok(result)
    }
}

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

    use SubmoduleRewind;

    /// Configuration for the `SubmoduleRewind` check.
    ///
    /// No configuration available.
    ///
    /// This check is registered as a commit check with the name `"submodule_rewind"`.
    #[derive(Deserialize, Debug)]
    pub struct SubmoduleRewindConfig {}

    impl IntoCheck for SubmoduleRewindConfig {
        type Check = SubmoduleRewind;

        fn into_check(self) -> Self::Check {
            SubmoduleRewind::default()
        }
    }

    register_checks! {
        SubmoduleRewindConfig {
            "submodule_rewind" => CommitCheckConfig,
        },
    }

    #[test]
    fn test_submodule_rewind_config_empty() {
        let json = json!({});
        serde_json::from_value::<SubmoduleRewindConfig>(json).unwrap();
    }
}

#[cfg(test)]
mod tests {
    use test::*;
    use SubmoduleRewind;

    const MOVE_TOPIC: &str = "2088079e35503be3be41dbdca55080ced95614e1";
    const REWIND_TOPIC: &str = "39c5d0d9dc7ee6abad72cd42c90d7c1af1be169c";
    const TO_UNAVAILABLE_TOPIC: &str = "1b9275caca1557611df19d1dfea687c3ef302eef";
    const FROM_UNAVAILABLE_TOPIC: &str = "4d33c389cedef6fe4003ae05633fd2356bcd2acc";

    #[test]
    fn test_submodule_rewind_builder_default() {
        assert!(SubmoduleRewind::builder().build().is_ok());
    }

    #[test]
    fn test_submodule_rewind_ok() {
        let check = SubmoduleRewind::default();
        let conf = make_check_conf(&check);

        let result = test_check_submodule("test_submodule_rewind_ok", MOVE_TOPIC, &conf);
        test_result_ok(result);
    }

    #[test]
    fn test_submodule_rewind_to_unavailable() {
        let check = SubmoduleRewind::default();
        let conf = make_check_conf(&check);

        let result = test_check_submodule(
            "test_submodule_rewind_to_unavailable",
            TO_UNAVAILABLE_TOPIC,
            &conf,
        );

        // No errors because we can't give an answer due to not having the referenced commits
        // locally..
        test_result_ok(result);
    }

    #[test]
    fn test_submodule_rewind_from_unavailable() {
        let check = SubmoduleRewind::default();
        let conf = make_check_conf(&check);

        let result = test_check_submodule(
            "test_submodule_rewind_from_unavailable",
            FROM_UNAVAILABLE_TOPIC,
            &conf,
        );

        // No errors because we can't give an answer due to not having the referenced commits
        // locally..
        test_result_ok(result);
    }

    #[test]
    fn test_submodule_rewind_rewind() {
        let check = SubmoduleRewind::default();
        let conf = make_check_conf(&check);

        let result = test_check_submodule_base(
            "test_submodule_rewind_rewind",
            REWIND_TOPIC,
            MOVE_TOPIC,
            &conf,
        );
        test_result_errors(result, &[
            "commit 39c5d0d9dc7ee6abad72cd42c90d7c1af1be169c is not allowed since it moves the \
             submodule `submodule` backwards from 8a890d8c4b89560c70a059bbdd7bc59b92b5c92b to \
             2a8baa8e23bb1de5eec202dd4a29adf47feb03b1.",
        ]);
    }
}